fix: Filter source control credentials by project (#16732)

This commit is contained in:
Raúl Gómez Morales
2025-06-27 16:47:46 +02:00
committed by GitHub
parent 1934e6fc0f
commit 0debbc3503
3 changed files with 133 additions and 21 deletions

View File

@@ -82,6 +82,7 @@
"generic.upgrade": "Upgrade", "generic.upgrade": "Upgrade",
"generic.upgradeNow": "Upgrade now", "generic.upgradeNow": "Upgrade now",
"generic.credential": "Credential | {count} Credential | {count} Credentials", "generic.credential": "Credential | {count} Credential | {count} Credentials",
"generic.credentials": "Credentials",
"generic.workflow": "Workflow | {count} Workflow | {count} Workflows", "generic.workflow": "Workflow | {count} Workflow | {count} Workflows",
"generic.workflowSaved": "Workflow changes saved", "generic.workflowSaved": "Workflow changes saved",
"generic.editor": "Editor", "generic.editor": "Editor",
@@ -2188,6 +2189,7 @@
"settings.sourceControl.modals.push.description": "The following will be committed: ", "settings.sourceControl.modals.push.description": "The following will be committed: ",
"settings.sourceControl.modals.push.description.learnMore": "More info", "settings.sourceControl.modals.push.description.learnMore": "More info",
"settings.sourceControl.modals.push.filesToCommit": "Files to commit", "settings.sourceControl.modals.push.filesToCommit": "Files to commit",
"settings.sourceControl.modals.push.filter": "Filters are applied. Showing {count} {entity}.",
"settings.sourceControl.modals.push.workflowsToCommit": "Select workflows", "settings.sourceControl.modals.push.workflowsToCommit": "Select workflows",
"settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date", "settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date",
"settings.sourceControl.modals.push.noWorkflowChanges": "There are no workflow changes but the following will be committed: {link}", "settings.sourceControl.modals.push.noWorkflowChanges": "There are no workflow changes but the following will be committed: {link}",
@@ -2198,6 +2200,7 @@
"settings.sourceControl.modals.push.buttons.save": "Commit and push", "settings.sourceControl.modals.push.buttons.save": "Commit and push",
"settings.sourceControl.modals.push.success.title": "Pushed successfully", "settings.sourceControl.modals.push.success.title": "Pushed successfully",
"settings.sourceControl.modals.push.success.description": "were committed and pushed to your remote repository", "settings.sourceControl.modals.push.success.description": "were committed and pushed to your remote repository",
"settings.sourceControl.modals.push.projectAdmin.callout": "If you want to push workflows from your personal space, move then to a project first.",
"settings.sourceControl.status.modified": "Modified", "settings.sourceControl.status.modified": "Modified",
"settings.sourceControl.status.deleted": "Deleted", "settings.sourceControl.status.deleted": "Deleted",
"settings.sourceControl.status.created": "New", "settings.sourceControl.status.created": "New",

View File

@@ -323,7 +323,6 @@ describe('SourceControlPushModal', () => {
}); });
it('should show credentials in a different tab', async () => { it('should show credentials in a different tab', async () => {
// source-control-push-modal-tab
const status: SourceControlledFile[] = [ const status: SourceControlledFile[] = [
{ {
id: 'gTbbBkkYTnNyX1jD', id: 'gTbbBkkYTnNyX1jD',
@@ -486,15 +485,18 @@ describe('SourceControlPushModal', () => {
}); });
}); });
it('should filter by project', async () => { test.each([
['credential', 'Credentials'],
['workflow', 'Workflows'],
])('should filter %s by project', async (entity, name) => {
const projectsStore = mockedStore(useProjectsStore); const projectsStore = mockedStore(useProjectsStore);
projectsStore.availableProjects = projects as unknown as ProjectListItem[]; projectsStore.availableProjects = projects as unknown as ProjectListItem[];
const status: SourceControlledFile[] = [ const status: SourceControlledFile[] = [
{ {
id: 'gTbbBkkYTnNyX1jD', id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1', name: `My ${name} 1`,
type: 'workflow', type: entity as SourceControlledFile['type'],
status: 'created', status: 'created',
location: 'local', location: 'local',
conflict: false, conflict: false,
@@ -508,8 +510,8 @@ describe('SourceControlPushModal', () => {
}, },
{ {
id: 'JIGKevgZagmJAnM6', id: 'JIGKevgZagmJAnM6',
name: 'My workflow 2', name: `My ${name} 1`,
type: 'workflow', type: entity as SourceControlledFile['type'],
status: 'created', status: 'created',
location: 'local', location: 'local',
conflict: false, conflict: false,
@@ -532,6 +534,12 @@ describe('SourceControlPushModal', () => {
}, },
}); });
const tab = getAllByTestId('source-control-push-modal-tab').filter(({ textContent }) =>
textContent?.includes(name),
);
await userEvent.click(tab[0]);
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2); expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2);
await userEvent.click(getByTestId('source-control-filter-dropdown')); await userEvent.click(getByTestId('source-control-filter-dropdown'));
@@ -545,6 +553,9 @@ describe('SourceControlPushModal', () => {
await userEvent.click(getAllByTestId('project-sharing-info')[0]); await userEvent.click(getAllByTestId('project-sharing-info')[0]);
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1); expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1);
expect(getByTestId('source-control-push-modal-file-checkbox')).toHaveTextContent(
`My ${name} 1`,
);
}); });
it('should reset', async () => { it('should reset', async () => {

View File

@@ -4,14 +4,17 @@ import { useLoadingService } from '@/composables/useLoadingService';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants'; import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import type { WorkflowResource } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types'; import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import { ResourceType } from '@/utils/projects.utils'; import { ResourceType } from '@/utils/projects.utils';
import { getPushPriorityByStatus, getStatusText, getStatusTheme } from '@/utils/sourceControlUtils'; import { getPushPriorityByStatus, getStatusText, getStatusTheme } from '@/utils/sourceControlUtils';
import type { SourceControlledFile } from '@n8n/api-types';
import { import {
type SourceControlledFile, ROLE,
SOURCE_CONTROL_FILE_LOCATION, SOURCE_CONTROL_FILE_LOCATION,
SOURCE_CONTROL_FILE_STATUS, SOURCE_CONTROL_FILE_STATUS,
SOURCE_CONTROL_FILE_TYPE, SOURCE_CONTROL_FILE_TYPE,
@@ -19,6 +22,7 @@ import {
import { import {
N8nBadge, N8nBadge,
N8nButton, N8nButton,
N8nCallout,
N8nHeading, N8nHeading,
N8nIcon, N8nIcon,
N8nInput, N8nInput,
@@ -32,7 +36,7 @@ import {
} from '@n8n/design-system'; } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import { refDebounced } from '@vueuse/core'; import { refDebounced, useStorage } from '@vueuse/core';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch } from 'vue'; import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch } from 'vue';
@@ -40,7 +44,6 @@ import { useRoute } from 'vue-router';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { type WorkflowResource } from '@/Interface';
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlledFile[] }; data: { eventBus: EventBus; status: SourceControlledFile[] };
@@ -54,11 +57,25 @@ const sourceControlStore = useSourceControlStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const route = useRoute(); const route = useRoute();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const usersStore = useUsersStore();
const projectAdminCalloutDismissed = useStorage(
'SOURCE_CONTROL_PROJECT_ADMIN_CALLOUT_DISMISSED',
false,
localStorage,
);
onBeforeMount(() => { onBeforeMount(() => {
void projectsStore.getAvailableProjects(); void projectsStore.getAvailableProjects();
}); });
const projectsForFilters = computed(() => {
return projectsStore.availableProjects.filter(
// global admins role is empty...
(project) => !project.role || project.role === 'project:admin',
);
});
const concatenateWithAnd = (messages: string[]) => const concatenateWithAnd = (messages: string[]) =>
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages); new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages);
@@ -172,11 +189,31 @@ const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFileWithProject)
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow)); onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
const currentProject = computed(() => {
if (!route.params.projectId) {
return null;
}
const project = projectsStore.availableProjects.find(
(project) => project.id === route.params.projectId?.toString(),
);
if (!project) {
return null;
}
if (!project.role || project.role === 'project:admin') {
return project;
}
return null;
});
const filters = ref<{ status?: SourceControlledFileStatus; project: ProjectSharingData | null }>({ const filters = ref<{ status?: SourceControlledFileStatus; project: ProjectSharingData | null }>({
project: null, project: currentProject.value,
}); });
const filtersApplied = computed( const filtersApplied = computed(
() => Boolean(search.value) || Boolean(Object.keys(filters.value).length), () => Boolean(search.value) || Boolean(Object.values(filters.value).filter(Boolean).length),
); );
const resetFilters = () => { const resetFilters = () => {
filters.value = { project: null }; filters.value = { project: null };
@@ -244,6 +281,10 @@ const filteredCredentials = computed(() => {
return false; return false;
} }
if (credential.project && filters.value.project) {
return credential.project.id === filters.value.project.id;
}
return !(filters.value.status && filters.value.status !== credential.status); return !(filters.value.status && filters.value.status !== credential.status);
}); });
}); });
@@ -417,6 +458,10 @@ const allVisibleItemsSelected = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
const workflowsSet = new Set(sortedWorkflows.value.map(({ id }) => id)); const workflowsSet = new Set(sortedWorkflows.value.map(({ id }) => id));
if (!workflowsSet.size) {
return false;
}
const notSelectedVisibleItems = workflowsSet.difference(toRaw(activeSelection.value)); const notSelectedVisibleItems = workflowsSet.difference(toRaw(activeSelection.value));
return !Boolean(notSelectedVisibleItems.size); return !Boolean(notSelectedVisibleItems.size);
@@ -424,6 +469,9 @@ const allVisibleItemsSelected = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) { if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) {
const credentialsSet = new Set(sortedCredentials.value.map(({ id }) => id)); const credentialsSet = new Set(sortedCredentials.value.map(({ id }) => id));
if (!credentialsSet.size) {
return false;
}
const notSelectedVisibleItems = credentialsSet.difference(toRaw(activeSelection.value)); const notSelectedVisibleItems = credentialsSet.difference(toRaw(activeSelection.value));
return !Boolean(notSelectedVisibleItems.size); return !Boolean(notSelectedVisibleItems.size);
@@ -460,6 +508,14 @@ const activeDataSourceFiltered = computed(() => {
return []; return [];
}); });
const activeEntityLocale = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
return 'generic.workflows';
}
return 'generic.credentials';
});
const activeSelection = computed(() => { const activeSelection = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
return selectedWorkflows; return selectedWorkflows;
@@ -487,11 +543,11 @@ const tabs = computed(() => {
]; ];
}); });
const filtersActiveText = computed(() => { const filtersNoResultText = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
return i18n.baseText('workflows.filters.active'); return i18n.baseText('workflows.noResults');
} }
return i18n.baseText('credentials.filters.active'); return i18n.baseText('credentials.noResults');
}); });
function castType(type: string): ResourceType { function castType(type: string): ResourceType {
@@ -519,7 +575,11 @@ function castProject(project: ProjectListItem) {
{{ i18n.baseText('settings.sourceControl.modals.push.title') }} {{ i18n.baseText('settings.sourceControl.modals.push.title') }}
</N8nHeading> </N8nHeading>
<div v-if="changes.workflow.length" :class="[$style.filtersRow]" class="mt-l"> <div
v-if="changes.workflow.length || changes.credential.length"
:class="[$style.filtersRow]"
class="mt-l"
>
<div :class="[$style.filters]"> <div :class="[$style.filters]">
<N8nInput <N8nInput
v-model="search" v-model="search"
@@ -576,7 +636,7 @@ function castProject(project: ProjectListItem) {
<ProjectSharing <ProjectSharing
v-model="filters.project" v-model="filters.project"
data-test-id="source-control-push-modal-project-search" data-test-id="source-control-push-modal-project-search"
:projects="projectsStore.availableProjects" :projects="projectsForFilters"
:placeholder="i18n.baseText('forms.resourceFiltersDropdown.owner.placeholder')" :placeholder="i18n.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
:empty-options-text="i18n.baseText('projects.sharing.noMatchingProjects')" :empty-options-text="i18n.baseText('projects.sharing.noMatchingProjects')"
/> />
@@ -588,6 +648,26 @@ function castProject(project: ProjectListItem) {
</N8nPopover> </N8nPopover>
</div> </div>
</div> </div>
<template v-if="usersStore.currentUser && usersStore.currentUser.role">
<template
v-if="
usersStore.currentUser.role !== ROLE.Owner && usersStore.currentUser.role !== ROLE.Admin
"
>
<N8nCallout theme="secondary" class="mt-s" v-if="!projectAdminCalloutDismissed">
{{ i18n.baseText('settings.sourceControl.modals.push.projectAdmin.callout') }}
<template #trailingContent>
<N8nIcon
icon="times"
title="Dismiss"
size="medium"
type="secondary"
@click="projectAdminCalloutDismissed = true"
/>
</template>
</N8nCallout>
</template>
</template>
</template> </template>
<template #content> <template #content>
<div style="display: flex; height: 100%"> <div style="display: flex; height: 100%">
@@ -614,18 +694,25 @@ function castProject(project: ProjectListItem) {
:indeterminate="selectAllIndeterminate" :indeterminate="selectAllIndeterminate"
:model-value="allVisibleItemsSelected" :model-value="allVisibleItemsSelected"
data-test-id="source-control-push-modal-toggle-all" data-test-id="source-control-push-modal-toggle-all"
:disabled="activeDataSourceFiltered.length === 0"
@update:model-value="onToggleSelectAll" @update:model-value="onToggleSelectAll"
> >
<N8nText> Title </N8nText> <N8nText> Title </N8nText>
</N8nCheckbox> </N8nCheckbox>
</div>
<div style="flex: 1; overflow: hidden">
<N8nInfoTip <N8nInfoTip
v-if="filtersApplied && activeDataSource.length && !activeDataSourceFiltered.length" v-if="filtersApplied"
class="p-xs" class="p-xs"
:bold="false" :bold="false"
:class="$style.filtersApplied"
> >
{{ filtersActiveText }} {{
i18n.baseText('settings.sourceControl.modals.push.filter', {
interpolate: {
count: `${activeDataSourceFiltered.length} / ${activeDataSource.length}`,
entity: i18n.baseText(activeEntityLocale).toLowerCase(),
},
})
}}
<N8nLink <N8nLink
size="small" size="small"
data-test-id="source-control-filters-reset" data-test-id="source-control-filters-reset"
@@ -634,6 +721,11 @@ function castProject(project: ProjectListItem) {
{{ i18n.baseText('workflows.filters.active.reset') }} {{ i18n.baseText('workflows.filters.active.reset') }}
</N8nLink> </N8nLink>
</N8nInfoTip> </N8nInfoTip>
</div>
<div style="flex: 1; overflow: hidden">
<N8nInfoTip v-if="!activeDataSourceFiltered.length" class="p-xs" :bold="false">
{{ filtersNoResultText }}
</N8nInfoTip>
<DynamicScroller <DynamicScroller
v-if="activeDataSourceFiltered.length" v-if="activeDataSourceFiltered.length"
:class="[$style.scroller]" :class="[$style.scroller]"
@@ -784,6 +876,11 @@ function castProject(project: ProjectListItem) {
.selectAll { .selectAll {
flex-shrink: 0; flex-shrink: 0;
margin-bottom: 0; margin-bottom: 0;
padding: 10px 16px;
}
.filtersApplied {
border-top: var(--border-base);
} }
.scroller { .scroller {
@@ -863,7 +960,8 @@ function castProject(project: ProjectListItem) {
.tableHeader { .tableHeader {
border-bottom: var(--border-base); border-bottom: var(--border-base);
padding: 10px 16px; display: flex;
flex-direction: column;
} }
.tabs { .tabs {