diff --git a/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6bcc65694aa072e9909843e59cc33e9c05187967
--- /dev/null
+++ b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue
index 29dbc49f835aed0b6000f51ffa5d961e28fa9df6..ef5be9f499a17e958d1b09e454e7aa0d9bf3ad26 100644
--- a/app/assets/javascripts/token_access/components/inbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue
@@ -3,7 +3,8 @@ import {
GlAlert,
GlButton,
GlCard,
- GlFormInput,
+ GlForm,
+ GlFormGroup,
GlLink,
GlIcon,
GlLoadingIcon,
@@ -21,6 +22,7 @@ import inboundRemoveGroupCIJobTokenScopeMutation from '../graphql/mutations/inbo
import inboundUpdateCIJobTokenScopeMutation from '../graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql';
import inboundGetCIJobTokenScopeQuery from '../graphql/queries/inbound_get_ci_job_token_scope.query.graphql';
import inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery from '../graphql/queries/inbound_get_groups_and_projects_with_ci_job_token_scope.query.graphql';
+import GroupsAndProjectsListbox from './groups_and_projects_listbox.vue';
import TokenAccessTable from './token_access_table.vue';
export default {
@@ -36,22 +38,23 @@ export default {
addGroupOrProject: __('Add group or project'),
add: __('Add'),
cancel: __('Cancel'),
- addProjectPlaceholder: __(
- 'Paste group path (i.e. gitlab-org) or project path (i.e. gitlab-org/gitlab)',
- ),
+ addProjectPlaceholder: __('Pick a group or project'),
projectsFetchError: __('There was a problem fetching the projects'),
scopeFetchError: __('There was a problem fetching the job token scope value'),
+ projectInScopeError: s__('CICD|Target project is already in the job token scope.'),
},
components: {
GlAlert,
GlButton,
GlCard,
- GlFormInput,
+ GlForm,
+ GlFormGroup,
GlLink,
GlIcon,
GlLoadingIcon,
GlSprintf,
GlToggle,
+ GroupsAndProjectsListbox,
TokenAccessTable,
},
directives: {
@@ -112,6 +115,11 @@ export default {
isGroupOrProjectPathEmpty() {
return this.groupOrProjectPath === '';
},
+ isGroupOrProjectPathInScope() {
+ return this.groupsAndProjectsWithAccess.some(
+ (item) => item.fullPath === this.groupOrProjectPath,
+ );
+ },
ciJobTokenHelpPage() {
return helpPagePath('ci/jobs/ci_job_token#control-job-token-access-to-your-project');
},
@@ -219,6 +227,9 @@ export default {
this.getGroupsAndProjects();
}
},
+ setGroupOrProjectPath(path) {
+ this.groupOrProjectPath = path;
+ },
clearGroupOrProjectPath() {
this.groupOrProjectPath = '';
this.isAddFormVisible = false;
@@ -312,23 +323,33 @@ export default {
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..4d59dcebd3970e1c57ba2af284d06f3e5abeb7b5
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql
@@ -0,0 +1,18 @@
+query getTokenAccessGroupsAndProjects($search: String) {
+ groups(search: $search, first: 4) {
+ nodes {
+ id
+ name
+ avatarUrl
+ fullPath
+ }
+ }
+ projects(search: $search, first: 4) {
+ nodes {
+ id
+ name
+ avatarUrl
+ fullPath
+ }
+ }
+}
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 1afd8e113877a04ec266b62949e0f4676a08f79b..82185d52cb45555dcc69dcb6c4231ccb3f457155 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -10172,6 +10172,9 @@ msgstr ""
msgid "CICD|Prevent CI/CD job tokens from this project from being used to access other projects unless the other project is added to the allowlist. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more%{linkEnd}."
msgstr ""
+msgid "CICD|Target project is already in the job token scope."
+msgstr ""
+
msgid "CICD|The %{boldStart}Limit access %{boldEnd}%{italicAndBoldStart}from%{italicAndBoldEnd}%{boldStart} this project%{boldEnd} setting is deprecated and will be removed in the 18.0 milestone. Use the %{boldStart}Limit access %{boldEnd}%{italicAndBoldStart}to%{italicAndBoldEnd}%{boldStart} this project%{boldEnd} setting and allowlist instead. %{linkStart}How do I do this?%{linkEnd}"
msgstr ""
@@ -38036,9 +38039,6 @@ msgstr ""
msgid "Paste a public key here. %{link_start}How do I generate it?%{link_end}"
msgstr ""
-msgid "Paste group path (i.e. gitlab-org) or project path (i.e. gitlab-org/gitlab)"
-msgstr ""
-
msgid "Paste project path (i.e. gitlab-org/gitlab)"
msgstr ""
@@ -38306,6 +38306,9 @@ msgstr ""
msgid "PhoneVerification|You've reached the maximum number of tries. Wait %{interval} and try again."
msgstr ""
+msgid "Pick a group or project"
+msgstr ""
+
msgid "Pick a name"
msgstr ""
diff --git a/spec/frontend/token_access/groups_and_projects_listbox_spec.js b/spec/frontend/token_access/groups_and_projects_listbox_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..1c492bb856a73b395a8767bc97f83cd391d17517
--- /dev/null
+++ b/spec/frontend/token_access/groups_and_projects_listbox_spec.js
@@ -0,0 +1,101 @@
+import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import GroupsAndProjectsListbox from '~/token_access/components/groups_and_projects_listbox.vue';
+import getGroupsAndProjectsQuery from '~/token_access/graphql/queries/get_groups_and_projects.query.graphql';
+import { getGroupsAndProjectsResponse } from './mock_data';
+
+const placeholder = 'Pick a group or project';
+
+Vue.use(VueApollo);
+
+describe('GroupsAndProjectsListbox component', () => {
+ let wrapper;
+
+ const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+ const openListbox = () => findListbox().vm.$emit('shown');
+ const findListboxItems = () => wrapper.findAllComponents(GlListboxItem);
+
+ const createComponent = ({
+ mountFn = mountExtended,
+ requestHandlers = [
+ [
+ getGroupsAndProjectsQuery,
+ jest.fn().mockImplementation(({ search }) => {
+ if (search !== '') {
+ return {
+ data: {
+ groups: {
+ nodes: [getGroupsAndProjectsResponse.data.groups.nodes[0]],
+ },
+ projects: {
+ nodes: [getGroupsAndProjectsResponse.data.projects.nodes[0]],
+ },
+ },
+ };
+ }
+ return getGroupsAndProjectsResponse;
+ }),
+ ],
+ ],
+ value = '',
+ } = {}) => {
+ wrapper = mountFn(GroupsAndProjectsListbox, {
+ apolloProvider: createMockApollo(requestHandlers),
+ propsData: {
+ value,
+ placeholder,
+ },
+ });
+ };
+
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('displays a listbox', () => {
+ expect(findListbox().exists()).toBe(true);
+ });
+
+ it('displays the placeholder as the toggle text', () => {
+ expect(findListbox().props('toggleText')).toBe(placeholder);
+ });
+
+ it('lists groups and projects', () => {
+ expect(findListbox().props('items')).toMatchObject([
+ { text: 'Groups', options: getGroupsAndProjectsResponse.data.groups.nodes },
+ { text: 'Projects', options: getGroupsAndProjectsResponse.data.projects.nodes },
+ ]);
+ });
+
+ it('searches for groups and projects', async () => {
+ expect(findListbox().props('items')[0].options).toHaveLength(
+ getGroupsAndProjectsResponse.data.groups.nodes.length,
+ );
+ expect(findListbox().props('items')[1].options).toHaveLength(
+ getGroupsAndProjectsResponse.data.projects.nodes.length,
+ );
+
+ openListbox();
+ findListbox().vm.$emit('search', 'gitlab');
+
+ await waitForPromises();
+
+ expect(findListbox().props('items')[0].options).toHaveLength(1);
+ expect(findListbox().props('items')[1].options).toHaveLength(1);
+ });
+
+ it('emits select event with the fullPath when an item is selected', () => {
+ openListbox();
+ findListboxItems().at(0).trigger('click');
+
+ expect(wrapper.emitted('select')[0][0]).toEqual(
+ getGroupsAndProjectsResponse.data.groups.nodes[0].fullPath,
+ );
+ });
+});
diff --git a/spec/frontend/token_access/inbound_token_access_spec.js b/spec/frontend/token_access/inbound_token_access_spec.js
index 045156075c9d6826bab42afeab8028c35bc6904c..78a33817d928b9264017fd9d21afc35b957ad754 100644
--- a/spec/frontend/token_access/inbound_token_access_spec.js
+++ b/spec/frontend/token_access/inbound_token_access_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlFormInput, GlToggle, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlCollapsibleListbox, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -6,17 +6,20 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import InboundTokenAccess from '~/token_access/components/inbound_token_access.vue';
+import GroupsAndProjectsListbox from '~/token_access/components/groups_and_projects_listbox.vue';
import inboundAddGroupOrProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_add_group_or_project_ci_job_token_scope.mutation.graphql';
import inboundRemoveGroupCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_group_ci_job_token_scope.mutation.graphql';
import inboundRemoveProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql';
import inboundUpdateCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql';
import inboundGetCIJobTokenScopeQuery from '~/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql';
import inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery from '~/token_access/graphql/queries/inbound_get_groups_and_projects_with_ci_job_token_scope.query.graphql';
+import getGroupsAndProjectsQuery from '~/token_access/graphql/queries/get_groups_and_projects.query.graphql';
import {
inboundJobTokenScopeEnabledResponse,
inboundJobTokenScopeDisabledResponse,
inboundGroupsAndProjectsWithScopeResponse,
inboundGroupsAndProjectsWithScopeResponseWithAddedItem,
+ getGroupsAndProjectsResponse,
inboundAddGroupOrProjectSuccessResponse,
inboundRemoveGroupSuccess,
inboundRemoveProjectSuccess,
@@ -45,6 +48,9 @@ describe('TokenAccess component', () => {
const inboundGroupsAndProjectsWithScopeResponseHandler = jest
.fn()
.mockResolvedValue(inboundGroupsAndProjectsWithScopeResponse);
+ const getGroupsAndProjectsSuccessResponseHandler = jest
+ .fn()
+ .mockResolvedValue(getGroupsAndProjectsResponse);
const inboundAddGroupOrProjectSuccessResponseHandler = jest
.fn()
.mockResolvedValue(inboundAddGroupOrProjectSuccessResponse);
@@ -61,7 +67,9 @@ describe('TokenAccess component', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAddProjectBtn = () => wrapper.findByTestId('add-project-btn');
const findCancelBtn = () => wrapper.findByRole('button', { name: 'Cancel' });
- const findProjectInput = () => wrapper.findComponent(GlFormInput);
+ const findGroupOrProjectFormGroup = () => wrapper.findByTestId('group-or-project-form-group');
+ const findGroupsAndProjectsListbox = () => wrapper.findComponent(GroupsAndProjectsListbox);
+ const findListboxInput = () => wrapper.findComponent(GlCollapsibleListbox);
const findRemoveProjectBtnAt = (i) =>
wrapper.findAllByRole('button', { name: 'Remove access' }).at(i);
const findToggleFormBtn = () => wrapper.findByTestId('toggle-form-btn');
@@ -242,6 +250,7 @@ describe('TokenAccess component', () => {
inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
inboundGroupsAndProjectsWithScopeResponseHandler,
],
+ [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler],
[
inboundAddGroupOrProjectCIJobTokenScopeMutation,
inboundAddGroupOrProjectSuccessResponseHandler,
@@ -253,7 +262,7 @@ describe('TokenAccess component', () => {
await waitForPromises();
await findToggleFormBtn().trigger('click');
- await findProjectInput().vm.$emit('input', testPath);
+ await findListboxInput().vm.$emit('select', testPath);
findAddProjectBtn().trigger('click');
expect(inboundAddGroupOrProjectSuccessResponseHandler).toHaveBeenCalledWith({
@@ -273,6 +282,7 @@ describe('TokenAccess component', () => {
.mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponse)
.mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponseWithAddedItem),
],
+ [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler],
[
inboundAddGroupOrProjectCIJobTokenScopeMutation,
inboundAddGroupOrProjectSuccessResponseHandler,
@@ -289,7 +299,7 @@ describe('TokenAccess component', () => {
);
await findToggleFormBtn().trigger('click');
- await findProjectInput().vm.$emit('input', testPath);
+ await findListboxInput().vm.$emit('select', testPath);
findAddProjectBtn().trigger('click');
await waitForPromises();
@@ -308,6 +318,7 @@ describe('TokenAccess component', () => {
inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
inboundGroupsAndProjectsWithScopeResponseHandler,
],
+ [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler],
[inboundAddGroupOrProjectCIJobTokenScopeMutation, failureHandler],
],
mountExtended,
@@ -316,8 +327,8 @@ describe('TokenAccess component', () => {
await waitForPromises();
await findToggleFormBtn().trigger('click');
- await findProjectInput().vm.$emit('input', testPath);
- findAddProjectBtn().trigger('click');
+ await findListboxInput().vm.$emit('select', testPath);
+ await findAddProjectBtn().trigger('click');
await waitForPromises();
@@ -332,6 +343,7 @@ describe('TokenAccess component', () => {
inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
inboundGroupsAndProjectsWithScopeResponseHandler,
],
+ [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler],
],
mountExtended,
);
@@ -340,17 +352,53 @@ describe('TokenAccess component', () => {
await findToggleFormBtn().trigger('click');
- expect(findProjectInput().exists()).toBe(true);
-
- await findProjectInput().vm.$emit('input', testPath);
+ expect(findListboxInput().exists()).toBe(true);
+ await findListboxInput().vm.$emit('select', testPath);
await findCancelBtn().trigger('click');
- expect(findProjectInput().exists()).toBe(false);
+ expect(findListboxInput().exists()).toBe(false);
await findToggleFormBtn().trigger('click');
- expect(findProjectInput().element.value).toBe('');
+ expect(findListboxInput().props('selected')).toEqual('');
+ });
+ });
+
+ describe.each`
+ type | testPath
+ ${'group'} | ${inboundGroupsAndProjectsWithScopeResponse.data.project.ciJobTokenScope.inboundAllowlist.nodes[0].fullPath}
+ ${'project'} | ${inboundGroupsAndProjectsWithScopeResponse.data.project.ciJobTokenScope.groupsAllowlist.nodes[0].fullPath}
+ `('add a duplicate $type', ({ testPath }) => {
+ it(`validates whether path is already in the allowlist`, async () => {
+ createComponent(
+ [
+ [inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler],
+ [
+ inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery,
+ inboundGroupsAndProjectsWithScopeResponseHandler,
+ ],
+ [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler],
+ [
+ inboundAddGroupOrProjectCIJobTokenScopeMutation,
+ inboundAddGroupOrProjectSuccessResponseHandler,
+ ],
+ ],
+ mountExtended,
+ );
+
+ await waitForPromises();
+
+ await findToggleFormBtn().trigger('click');
+
+ expect(findGroupOrProjectFormGroup().attributes('aria-invalid')).toBe(undefined);
+ expect(findGroupsAndProjectsListbox().props('isValid')).toBe(true);
+
+ await findListboxInput().vm.$emit('select', testPath);
+
+ expect(findGroupOrProjectFormGroup().attributes('aria-invalid')).toBe('true');
+ expect(findGroupsAndProjectsListbox().props('isValid')).toBe(false);
+ expect(findAddProjectBtn().props('disabled')).toBe(true);
});
});
diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js
index ab8f6d63c591522a6562590d5992c493f77ef195..54cb7788e2048f968808fc0a86889a1b72a2d97e 100644
--- a/spec/frontend/token_access/mock_data.js
+++ b/spec/frontend/token_access/mock_data.js
@@ -236,6 +236,23 @@ export const inboundGroupsAndProjectsWithScopeResponseWithAddedItem = {
},
};
+export const getGroupsAndProjectsResponse = {
+ data: {
+ groups: {
+ nodes: [
+ { id: 1, name: 'gitlab-org', avatarUrl: '', fullPath: 'gitlab-org' },
+ { id: 2, name: 'ci-group', avatarUrl: '', fullPath: 'root/ci-group' },
+ ],
+ },
+ projects: {
+ nodes: [
+ { id: 1, name: 'gitlab', avatarUrl: '', fullPath: 'gitlab-org/gitlab' },
+ { id: 2, name: 'ci-project', avatarUrl: '', fullPath: 'root/ci-project' },
+ ],
+ },
+ },
+};
+
export const inboundAddGroupOrProjectSuccessResponse = {
data: {
ciJobTokenScopeAddProject: {