Skip to content

Commit 84966aa

Browse files
authored
feat(editor): Use single select dropdown for selecting scope of a secrets store (n8n-io#26146)
1 parent 577e4ef commit 84966aa

5 files changed

Lines changed: 123 additions & 57 deletions

File tree

packages/frontend/@n8n/i18n/src/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3005,7 +3005,8 @@
30053005
"settings.secretsProviderConnections.modal.scope.placeholder.project": "Assign globally or within selected project",
30063006
"settings.secretsProviderConnections.modal.scope.global": "Global",
30073007
"settings.secretsProviderConnections.modal.scope.emptyOptionsText": "No matching projects found",
3008-
"settings.secretsProviderConnections.modal.scope.info": "Assigning a secret store allows people to use external secrets in their credentials.",
3008+
"settings.secretsProviderConnections.modal.scope.info": "Selecting a project will share the secrets store with that project only. If you want to share the secrets store with all projects, select \"Global\".",
3009+
"settings.secretsProviderConnections.modal.scope.label": "Scope",
30093010
"settings.secretsProviderConnections.modal.connectionName": "Vault name",
30103011
"settings.secretsProviderConnections.modal.providerType": "External secrets provider",
30113012
"settings.secretsProviderConnections.modal.providerType.placeholder": "External secrets provider",

packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectExternalSecrets.vue

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ const emptyStateConfig = computed(() => {
9898
'projects.settings.externalSecrets.emptyState.instanceAdmin.noProjectProviders.description',
9999
),
100100
buttonText: i18n.baseText('projects.settings.externalSecrets.button.shareSecretsStore'),
101-
buttonType: 'secondary' as const,
102101
buttonAction: onShareSecretsStore,
103102
testId: 'external-secrets-empty-state-no-project-providers',
104103
},
@@ -108,12 +107,10 @@ const emptyStateConfig = computed(() => {
108107
'projects.settings.externalSecrets.emptyState.projectAdmin.description',
109108
),
110109
buttonText: i18n.baseText('projects.settings.externalSecrets.button.addSecretsStore'),
111-
buttonType: 'secondary' as const,
112110
buttonAction: onAddSecretsStore,
113111
testId: 'external-secrets-empty-state-project-admin',
114112
},
115113
};
116-
117114
return configs[type];
118115
});
119116
@@ -298,12 +295,7 @@ defineExpose({
298295
</h3>
299296

300297
<!-- Empty State: Consolidated view based on user role and current state -->
301-
<N8nActionBox
302-
v-if="emptyStateConfig"
303-
:class="$style.externalSecretsEmpty"
304-
:data-test-id="emptyStateConfig.testId"
305-
description="yes"
306-
>
298+
<N8nActionBox v-if="emptyStateConfig" :data-test-id="emptyStateConfig.testId" description="yes">
307299
<template #description>
308300
<N8nHeading tag="h3" size="small" class="mb-2xs">
309301
{{ emptyStateConfig.heading }}
@@ -314,7 +306,8 @@ defineExpose({
314306
</template>
315307
<template #additionalContent>
316308
<N8nButton
317-
type="highlight"
309+
variant="ghost"
310+
size="xsmall"
318311
class="mr-2xs"
319312
element="a"
320313
:href="i18n.baseText('settings.externalSecrets.docs')"
@@ -324,7 +317,8 @@ defineExpose({
324317
{{ i18n.baseText('generic.learnMore') }} <N8nIcon icon="arrow-up-right" />
325318
</N8nButton>
326319
<N8nButton
327-
:type="emptyStateConfig.buttonType"
320+
variant="subtle"
321+
size="xsmall"
328322
:data-test-id="`${emptyStateType}-button`"
329323
@click="emptyStateConfig.buttonAction"
330324
>
@@ -425,10 +419,6 @@ defineExpose({
425419
</template>
426420

427421
<style lang="scss" module>
428-
.externalSecretsEmpty {
429-
margin-bottom: var(--spacing--lg);
430-
}
431-
432422
.description {
433423
max-width: 40rem;
434424
display: block;

packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/components/SecretsProviderConnectionModal.ee.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ const ModalStub = {
107107

108108
const mockProjects = orderBy(
109109
Array.from({ length: 3 }, () => createProjectListItem('team')),
110-
// Sort by type and name as in ProjectSharing component
111110
['type', (project) => project.name?.toLowerCase()],
112111
['desc', 'asc'],
113112
);
@@ -453,15 +452,15 @@ describe('SecretsProviderConnectionModal', () => {
453452

454453
await nextTick();
455454

456-
const projectSelect = queryByTestId('project-sharing-select');
455+
const projectSelect = queryByTestId('secrets-provider-scope-select');
457456

458457
expect(projectSelect).toBeInTheDocument();
459458

460459
await userEvent.click(projectSelect as HTMLElement);
461460
const projectSelectDropdownItems = await getDropdownItems(projectSelect as HTMLElement);
462461

463462
expect(projectSelectDropdownItems.length).toBeGreaterThan(1);
464-
// The first item is "All users" (global), so select the second item (team project)
463+
// The first item is "Global", so select the second item (team project)
465464
const teamProject = projectSelectDropdownItems[1];
466465

467466
await userEvent.click(teamProject as HTMLElement);
@@ -492,7 +491,7 @@ describe('SecretsProviderConnectionModal', () => {
492491

493492
await nextTick();
494493

495-
const projectSelect = queryByTestId('project-sharing-select');
494+
const projectSelect = queryByTestId('secrets-provider-scope-select');
496495

497496
expect(projectSelect).toBeInTheDocument();
498497

packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/components/SecretsProviderConnectionModal.ee.vue

Lines changed: 111 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ import {
3232
N8nTooltip,
3333
type IMenuItem,
3434
} from '@n8n/design-system';
35-
import ProjectSharing from '@/features/collaboration/projects/components/ProjectSharing.vue';
3635
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
37-
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
36+
import type { IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
3837
import { useUIStore } from '@/app/stores/ui.store';
3938
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
4039
import Banner from '@/app/components/Banner.vue';
40+
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
4141
4242
// Props
4343
const props = withDefaults(
@@ -116,20 +116,49 @@ const sidebarItems = computed(() => {
116116
return menuItems;
117117
});
118118
119-
const scopeProjects = computed(() =>
120-
projectsStore.teamProjects.filter(
121-
(p: ProjectSharingData) => !modal.projectIds.value.includes(p.id),
122-
),
119+
const scopeOptions = computed<Array<{ value: string; label: string; icon: IconOrEmoji }>>(() => {
120+
const options: Array<{ value: string; label: string; icon: IconOrEmoji }> = [
121+
{
122+
value: '',
123+
label: i18n.baseText('settings.secretsProviderConnections.modal.scope.global'),
124+
icon: { type: 'icon', value: 'globe' },
125+
},
126+
];
127+
128+
options.push(
129+
...projectsStore.teamProjects.map((project: ProjectSharingData) => {
130+
const icon = (project.icon ?? {
131+
type: 'icon' as const,
132+
value: 'layer-group',
133+
}) as IconOrEmoji;
134+
return {
135+
value: project.id,
136+
label: project.name ?? project.id,
137+
icon,
138+
};
139+
}),
140+
);
141+
142+
return options;
143+
});
144+
145+
const scopeSelectValue = computed(() =>
146+
modal.isSharedGlobally.value ? '' : (modal.projectIds.value[0] ?? ''),
123147
);
124148
125-
// Sync scope changes to composable (max 1 project)
126-
function handleScopeUpdate(value: ProjectSharingData[] | ProjectSharingData | null) {
127-
const project = Array.isArray(value) ? value.at(-1) : value;
128-
modal.setScopeState(project ? [project.id] : [], false);
129-
}
149+
const selectedScopeIcon = computed<IconOrEmoji>(() => {
150+
const selectedOption = scopeOptions.value.find(
151+
(option) => option.value === scopeSelectValue.value,
152+
);
153+
return selectedOption?.icon ?? { type: 'icon' as const, value: 'globe' };
154+
});
130155
131-
function handleShareGlobally(value: boolean) {
132-
modal.setScopeState([], value);
156+
function handleScopeSelect(value: string) {
157+
if (value === '') {
158+
modal.setScopeState([], true);
159+
} else {
160+
modal.setScopeState([value], false);
161+
}
133162
}
134163
135164
// Handlers
@@ -423,29 +452,48 @@ onMounted(async () => {
423452
<N8nInfoTip :bold="false" class="mb-s">
424453
{{ i18n.baseText('settings.secretsProviderConnections.modal.scope.info') }}
425454
</N8nInfoTip>
426-
<ProjectSharing
427-
:model-value="modal.sharedWithProjects.value"
428-
:projects="scopeProjects"
429-
:readonly="!modal.canUpdate.value"
430-
:static="!modal.canUpdate.value"
431-
:placeholder="
432-
i18n.baseText(
433-
'settings.secretsProviderConnections.modal.scope.placeholder.project',
434-
)
435-
"
436-
:all-users-label="
437-
i18n.baseText('settings.secretsProviderConnections.modal.scope.global')
438-
"
439-
:empty-options-text="
440-
i18n.baseText(
441-
'settings.secretsProviderConnections.modal.scope.emptyOptionsText',
442-
)
443-
"
444-
:can-share-globally="modal.canShareGlobally.value"
445-
:is-shared-globally="modal.isSharedGlobally.value"
446-
@update:share-with-all-users="handleShareGlobally"
447-
@update:model-value="handleScopeUpdate"
448-
/>
455+
<N8nInputLabel
456+
:label="i18n.baseText('settings.secretsProviderConnections.modal.scope.label')"
457+
>
458+
<N8nSelect
459+
:model-value="scopeSelectValue"
460+
size="large"
461+
filterable
462+
:disabled="!modal.canUpdate.value"
463+
data-test-id="secrets-provider-scope-select"
464+
@update:model-value="handleScopeSelect"
465+
>
466+
<template #prefix>
467+
<N8nText
468+
v-if="selectedScopeIcon?.type === 'emoji'"
469+
color="text-light"
470+
:class="$style.menuItemEmoji"
471+
>
472+
{{ selectedScopeIcon.value }}
473+
</N8nText>
474+
<N8nIcon
475+
v-else-if="selectedScopeIcon?.value"
476+
color="text-light"
477+
:icon="selectedScopeIcon.value"
478+
/>
479+
</template>
480+
<N8nOption
481+
v-for="option in scopeOptions"
482+
:key="option.value || 'global'"
483+
:value="option.value"
484+
:label="option.label"
485+
:class="{ [$style.globalOption]: option.value === '' }"
486+
>
487+
<div :class="$style.optionContent">
488+
<N8nText v-if="option.icon?.type === 'emoji'" :class="$style.menuItemEmoji">
489+
{{ option.icon.value }}
490+
</N8nText>
491+
<N8nIcon v-else-if="option.icon?.value" :icon="option.icon.value" />
492+
<span>{{ option.label }}</span>
493+
</div>
494+
</N8nOption>
495+
</N8nSelect>
496+
</N8nInputLabel>
449497
</div>
450498
</div>
451499
</div>
@@ -562,4 +610,31 @@ onMounted(async () => {
562610
display: block;
563611
margin-top: var(--spacing--4xs);
564612
}
613+
614+
.optionContent {
615+
display: flex;
616+
align-items: center;
617+
gap: var(--spacing--2xs);
618+
}
619+
620+
.menuItemEmoji {
621+
font-size: var(--font-size--sm);
622+
line-height: 1;
623+
}
624+
625+
.globalOption {
626+
position: relative;
627+
margin-bottom: var(--spacing--sm);
628+
overflow: visible;
629+
630+
&::after {
631+
content: '';
632+
position: absolute;
633+
bottom: calc(var(--spacing--2xs) * -1);
634+
left: var(--spacing--xs);
635+
right: var(--spacing--xs);
636+
height: 1px;
637+
background-color: var(--color--foreground);
638+
}
639+
}
565640
</style>

packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/views/SettingsSecretsProviders.ee.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ function goToUpgrade() {
173173
<N8nButton
174174
v-if="hasActiveProviders && secretsProviders.canCreate.value"
175175
:class="$style.addButton"
176-
type="primary"
176+
variant="solid"
177+
size="small"
177178
@click="openConnectionModal()"
178179
><N8nIcon icon="plus" />
179180
{{ i18n.baseText('settings.secretsProviderConnections.buttons.addSecretsStore') }}

0 commit comments

Comments
 (0)