Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/frontend/@n8n/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2995,7 +2995,8 @@
"settings.secretsProviderConnections.modal.scope.placeholder.project": "Assign globally or within selected project",
"settings.secretsProviderConnections.modal.scope.global": "Global",
"settings.secretsProviderConnections.modal.scope.emptyOptionsText": "No matching projects found",
"settings.secretsProviderConnections.modal.scope.info": "Assigning a secret store allows people to use external secrets in their credentials.",
"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\".",
"settings.secretsProviderConnections.modal.scope.label": "Scope",
"settings.secretsProviderConnections.modal.connectionName": "Vault name",
"settings.secretsProviderConnections.modal.providerType": "External secrets provider",
"settings.secretsProviderConnections.modal.providerType.placeholder": "External secrets provider",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ const emptyStateConfig = computed(() => {
'projects.settings.externalSecrets.emptyState.instanceAdmin.noProjectProviders.description',
),
buttonText: i18n.baseText('projects.settings.externalSecrets.button.shareSecretsStore'),
buttonType: 'secondary' as const,
buttonAction: onShareSecretsStore,
testId: 'external-secrets-empty-state-no-project-providers',
},
Expand All @@ -108,12 +107,10 @@ const emptyStateConfig = computed(() => {
'projects.settings.externalSecrets.emptyState.projectAdmin.description',
),
buttonText: i18n.baseText('projects.settings.externalSecrets.button.addSecretsStore'),
buttonType: 'secondary' as const,
buttonAction: onAddSecretsStore,
testId: 'external-secrets-empty-state-project-admin',
},
};

return configs[type];
});

Expand Down Expand Up @@ -298,12 +295,7 @@ defineExpose({
</h3>

<!-- Empty State: Consolidated view based on user role and current state -->
<N8nActionBox
v-if="emptyStateConfig"
:class="$style.externalSecretsEmpty"
:data-test-id="emptyStateConfig.testId"
description="yes"
>
<N8nActionBox v-if="emptyStateConfig" :data-test-id="emptyStateConfig.testId" description="yes">
<template #description>
<N8nHeading tag="h3" size="small" class="mb-2xs">
{{ emptyStateConfig.heading }}
Expand All @@ -314,7 +306,8 @@ defineExpose({
</template>
<template #additionalContent>
<N8nButton
type="highlight"
variant="ghost"
size="xsmall"
class="mr-2xs"
element="a"
:href="i18n.baseText('settings.externalSecrets.docs')"
Expand All @@ -324,7 +317,8 @@ defineExpose({
{{ i18n.baseText('generic.learnMore') }} <N8nIcon icon="arrow-up-right" />
</N8nButton>
<N8nButton
:type="emptyStateConfig.buttonType"
variant="subtle"
size="xsmall"
:data-test-id="`${emptyStateType}-button`"
@click="emptyStateConfig.buttonAction"
>
Expand Down Expand Up @@ -425,10 +419,6 @@ defineExpose({
</template>

<style lang="scss" module>
.externalSecretsEmpty {
margin-bottom: var(--spacing--lg);
}

.description {
max-width: 40rem;
display: block;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ const ModalStub = {

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

await nextTick();

const projectSelect = queryByTestId('project-sharing-select');
const projectSelect = queryByTestId('secrets-provider-scope-select');

expect(projectSelect).toBeInTheDocument();

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

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

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

await nextTick();

const projectSelect = queryByTestId('project-sharing-select');
const projectSelect = queryByTestId('secrets-provider-scope-select');

expect(projectSelect).toBeInTheDocument();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ import {
N8nTooltip,
type IMenuItem,
} from '@n8n/design-system';
import ProjectSharing from '@/features/collaboration/projects/components/ProjectSharing.vue';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
import type { IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import { useUIStore } from '@/app/stores/ui.store';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import Banner from '@/app/components/Banner.vue';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';

// Props
const props = withDefaults(
Expand Down Expand Up @@ -116,20 +116,49 @@ const sidebarItems = computed(() => {
return menuItems;
});

const scopeProjects = computed(() =>
projectsStore.teamProjects.filter(
(p: ProjectSharingData) => !modal.projectIds.value.includes(p.id),
),
const scopeOptions = computed<Array<{ value: string; label: string; icon: IconOrEmoji }>>(() => {
const options: Array<{ value: string; label: string; icon: IconOrEmoji }> = [
{
value: '',
label: i18n.baseText('settings.secretsProviderConnections.modal.scope.global'),
icon: { type: 'icon', value: 'globe' },
},
];

options.push(
...projectsStore.teamProjects.map((project: ProjectSharingData) => {
const icon = (project.icon ?? {
type: 'icon' as const,
value: 'layer-group',
}) as IconOrEmoji;
return {
value: project.id,
label: project.name ?? project.id,
icon,
};
}),
);

return options;
});

const scopeSelectValue = computed(() =>
modal.isSharedGlobally.value ? '' : (modal.projectIds.value[0] ?? ''),
);

// Sync scope changes to composable (max 1 project)
function handleScopeUpdate(value: ProjectSharingData[] | ProjectSharingData | null) {
const project = Array.isArray(value) ? value.at(-1) : value;
modal.setScopeState(project ? [project.id] : [], false);
}
const selectedScopeIcon = computed<IconOrEmoji>(() => {
const selectedOption = scopeOptions.value.find(
(option) => option.value === scopeSelectValue.value,
);
return selectedOption?.icon ?? { type: 'icon' as const, value: 'globe' };
});

function handleShareGlobally(value: boolean) {
modal.setScopeState([], value);
function handleScopeSelect(value: string) {
if (value === '') {
modal.setScopeState([], true);
} else {
modal.setScopeState([value], false);
}
}

// Handlers
Expand Down Expand Up @@ -423,29 +452,48 @@ onMounted(async () => {
<N8nInfoTip :bold="false" class="mb-s">
{{ i18n.baseText('settings.secretsProviderConnections.modal.scope.info') }}
</N8nInfoTip>
<ProjectSharing
:model-value="modal.sharedWithProjects.value"
:projects="scopeProjects"
:readonly="!modal.canUpdate.value"
:static="!modal.canUpdate.value"
:placeholder="
i18n.baseText(
'settings.secretsProviderConnections.modal.scope.placeholder.project',
)
"
:all-users-label="
i18n.baseText('settings.secretsProviderConnections.modal.scope.global')
"
:empty-options-text="
i18n.baseText(
'settings.secretsProviderConnections.modal.scope.emptyOptionsText',
)
"
:can-share-globally="modal.canShareGlobally.value"
:is-shared-globally="modal.isSharedGlobally.value"
@update:share-with-all-users="handleShareGlobally"
@update:model-value="handleScopeUpdate"
/>
<N8nInputLabel
:label="i18n.baseText('settings.secretsProviderConnections.modal.scope.label')"
>
<N8nSelect
:model-value="scopeSelectValue"
size="large"
filterable
:disabled="!modal.canUpdate.value"
data-test-id="secrets-provider-scope-select"
@update:model-value="handleScopeSelect"
>
<template #prefix>
<N8nText
v-if="selectedScopeIcon?.type === 'emoji'"
color="text-light"
:class="$style.menuItemEmoji"
>
{{ selectedScopeIcon.value }}
</N8nText>
<N8nIcon
v-else-if="selectedScopeIcon?.value"
color="text-light"
:icon="selectedScopeIcon.value"
/>
</template>
<N8nOption
v-for="option in scopeOptions"
:key="option.value || 'global'"
:value="option.value"
:label="option.label"
:class="{ [$style.globalOption]: option.value === '' }"
>
<div :class="$style.optionContent">
<N8nText v-if="option.icon?.type === 'emoji'" :class="$style.menuItemEmoji">
{{ option.icon.value }}
</N8nText>
<N8nIcon v-else-if="option.icon?.value" :icon="option.icon.value" />
<span>{{ option.label }}</span>
</div>
</N8nOption>
</N8nSelect>
</N8nInputLabel>
</div>
</div>
</div>
Expand Down Expand Up @@ -562,4 +610,31 @@ onMounted(async () => {
display: block;
margin-top: var(--spacing--4xs);
}

.optionContent {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}

.menuItemEmoji {
font-size: var(--font-size--sm);
line-height: 1;
}

.globalOption {
position: relative;
margin-bottom: var(--spacing--sm);
overflow: visible;

&::after {
content: '';
position: absolute;
bottom: calc(var(--spacing--2xs) * -1);
left: var(--spacing--xs);
right: var(--spacing--xs);
height: 1px;
background-color: var(--color--foreground);
}
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ function goToUpgrade() {
<N8nButton
v-if="hasActiveProviders && secretsProviders.canCreate.value"
:class="$style.addButton"
type="primary"
variant="solid"
size="small"
@click="openConnectionModal()"
><N8nIcon icon="plus" />
{{ i18n.baseText('settings.secretsProviderConnections.buttons.addSecretsStore') }}
Expand Down