feat(editor): Show private credential connection state in credentials list (#31117)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart 2026-05-27 21:07:30 +02:00 committed by GitHub
parent f0ea4ed1f0
commit 3af0afcd28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 346 additions and 11 deletions

View File

@ -1126,6 +1126,9 @@
"credentials.item.owner": "Owner",
"credentials.item.readonly": "Read only",
"credentials.item.needsSetup": "Needs first setup",
"credentials.item.connect": "Connect",
"credentials.item.connect.tooltip": "Connect your own account to use this credential in workflows. Only you can use your private credential.",
"credentials.item.connected": "Connected",
"credentials.dynamic.tooltip": "This credential uses a resolver to pick the right account at runtime based on who runs the workflow.",
"credentials.dynamic.tooltipTitle": "Dynamic credentials",
"credentials.dynamic.badge": "Dynamic",

View File

@ -315,6 +315,7 @@ export type CredentialsResource = BaseResource & {
needsSetup: boolean;
isGlobal?: boolean;
isResolvable?: boolean;
connectedByMe?: boolean;
};
// Base resource types that are always available

View File

@ -3,12 +3,25 @@ import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import CredentialCard from './CredentialCard.vue';
import type { CredentialsResource } from '@/Interface';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useCredentialsStore } from '../credentials.store';
import type { FrontendSettings } from '@n8n/api-types';
import type { ICredentialsResponse } from '../credentials.types';
const mockAuthorize = vi.fn();
const mockIsOAuthCredentialType = vi.fn();
vi.mock('../composables/useCredentialOAuth', () => ({
useCredentialOAuth: () => ({
authorize: mockAuthorize,
isOAuthCredentialType: mockIsOAuthCredentialType,
}),
}));
const renderComponent = createComponentRenderer(CredentialCard);
@ -40,6 +53,9 @@ describe('CredentialCard', () => {
activeModules: ['dynamic-credentials'],
} as unknown as FrontendSettings;
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
mockAuthorize.mockReset();
mockIsOAuthCredentialType.mockReset();
mockIsOAuthCredentialType.mockReturnValue(true);
});
it('should render name and home project name', () => {
@ -208,4 +224,136 @@ describe('CredentialCard', () => {
expect(queryByTestId('credential-card-dynamic')).not.toBeInTheDocument();
});
});
describe('private credentials connect flow', () => {
const privateUnconnectedData = (overrides = {}) =>
createCredential({
id: 'cred-1',
isResolvable: true,
connectedByMe: false,
homeProject: { name: 'Test Project' },
...overrides,
});
it('should show the Connect button when private and not connected', () => {
const { getByTestId, queryByTestId } = renderComponent({
props: { data: privateUnconnectedData() },
});
expect(getByTestId('credential-card-connect')).toBeInTheDocument();
expect(queryByTestId('credential-card-not-connected')).not.toBeInTheDocument();
});
it('should still show project badge alongside the Connect button', () => {
const { getByTestId } = renderComponent({
props: { data: privateUnconnectedData() },
});
expect(getByTestId('card-badge')).toBeInTheDocument();
expect(getByTestId('credential-card-connect')).toBeInTheDocument();
});
it('should show Connected label when private and connected', () => {
const data = createCredential({
isResolvable: true,
connectedByMe: true,
homeProject: { name: 'Test Project' },
});
const { getByTestId, queryByTestId } = renderComponent({ props: { data } });
expect(queryByTestId('credential-card-connect')).not.toBeInTheDocument();
const connectedLabel = getByTestId('credential-card-connected');
expect(connectedLabel).toBeInTheDocument();
expect(connectedLabel).toHaveTextContent('Connected');
expect(getByTestId('card-badge')).toBeInTheDocument();
});
it('should not show Connected label for non-resolvable credentials', () => {
const data = createCredential({
isResolvable: false,
connectedByMe: true,
homeProject: { name: 'Test Project' },
});
const { queryByTestId, getByTestId } = renderComponent({ props: { data } });
expect(queryByTestId('credential-card-connected')).not.toBeInTheDocument();
expect(getByTestId('card-badge')).toBeInTheDocument();
});
it('should show sharing badge when credential is not resolvable', () => {
const data = createCredential({
isResolvable: false,
connectedByMe: false,
homeProject: { name: 'Test Project' },
});
const { queryByTestId, getByTestId } = renderComponent({ props: { data } });
expect(queryByTestId('credential-card-connect')).not.toBeInTheDocument();
expect(getByTestId('card-badge')).toBeInTheDocument();
});
it('should call authorize with the credential and emit "connected" on success', async () => {
const data = privateUnconnectedData();
const credential = { id: data.id, type: 'oAuth2Api' } as ICredentialsResponse;
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential);
mockAuthorize.mockResolvedValue(true);
const { getByTestId, emitted } = renderComponent({ props: { data } });
await userEvent.click(getByTestId('credential-card-connect'));
expect(mockAuthorize).toHaveBeenCalledWith(credential);
expect(emitted('connected')).toEqual([[data.id]]);
});
it('should fall back to opening the edit modal when the credential is not an OAuth type', async () => {
const data = privateUnconnectedData();
const credential = { id: data.id, type: 'notOAuthApi' } as ICredentialsResponse;
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential);
mockIsOAuthCredentialType.mockReturnValue(false);
const { getByTestId, emitted } = renderComponent({ props: { data } });
await userEvent.click(getByTestId('credential-card-connect'));
expect(mockIsOAuthCredentialType).toHaveBeenCalledWith('notOAuthApi');
expect(mockAuthorize).not.toHaveBeenCalled();
expect(emitted('click')).toEqual([[data.id]]);
expect(emitted('connected')).toBeUndefined();
});
it('should not emit "connected" if authorize fails', async () => {
const data = privateUnconnectedData();
const credential = { id: data.id, type: 'oAuth2Api' } as ICredentialsResponse;
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential);
mockAuthorize.mockResolvedValue(false);
const { getByTestId, emitted } = renderComponent({ props: { data } });
await userEvent.click(getByTestId('credential-card-connect'));
expect(mockAuthorize).toHaveBeenCalled();
expect(emitted('connected')).toBeUndefined();
});
it('should not open the edit modal when Connect button is clicked', async () => {
const data = privateUnconnectedData();
const credential = { id: data.id, type: 'oAuth2Api' } as ICredentialsResponse;
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential);
mockAuthorize.mockResolvedValue(true);
const { getByTestId, emitted } = renderComponent({ props: { data } });
await userEvent.click(getByTestId('credential-card-connect'));
expect(emitted('click')).toBeUndefined();
});
});
});

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import dateformat from 'dateformat';
import { MODAL_CONFIRM } from '@/app/constants';
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/features/collaboration/projects/projects.constants';
@ -17,10 +17,12 @@ import { useI18n } from '@n8n/i18n';
import { ResourceType } from '@/features/collaboration/projects/projects.utils';
import type { CredentialsResource } from '@/Interface';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
import { useCredentialOAuth } from '../composables/useCredentialOAuth';
import {
N8nActionToggle,
N8nBadge,
N8nButton,
N8nCard,
N8nIcon,
N8nText,
@ -34,6 +36,7 @@ const CREDENTIAL_LIST_ITEM_ACTIONS = {
const emit = defineEmits<{
click: [credentialId: string];
connected: [credentialId: string];
}>();
const props = withDefaults(
@ -55,12 +58,30 @@ const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials();
const { hasDependencies } = useDependencies();
const { authorize, isOAuthCredentialType } = useCredentialOAuth();
const isConnecting = ref(false);
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
const credentialType = computed(() =>
credentialsStore.getCredentialTypeByName(props.data.type ?? ''),
);
const credentialPermissions = computed(() => getResourcePermissions(props.data.scopes).credential);
const isPrivateUnconnected = computed(
() =>
isDynamicCredentialsEnabled.value &&
props.data.isResolvable === true &&
props.data.connectedByMe === false,
);
const isPrivateConnected = computed(
() =>
isDynamicCredentialsEnabled.value &&
props.data.isResolvable === true &&
props.data.connectedByMe === true,
);
const actions = computed(() => {
const items = [
{
@ -100,6 +121,29 @@ function onClick() {
emit('click', props.data.id);
}
async function onConnect() {
const credential = credentialsStore.getCredentialById(props.data.id);
if (!credential) return;
// Direct OAuth flow only applies to OAuth credential types. Fall back to
// the edit modal for anything else today only OAuth credentials can be
// resolvable, but this keeps the button safe if that ever changes.
if (!isOAuthCredentialType(credential.type)) {
onClick();
return;
}
isConnecting.value = true;
try {
const success = await authorize(credential);
if (success) {
emit('connected', props.data.id);
}
} finally {
isConnecting.value = false;
}
}
async function onAction(action: string) {
switch (action) {
case CREDENTIAL_LIST_ITEM_ACTIONS.OPEN:
@ -207,6 +251,30 @@ function moveResource() {
:show-badge-border="false"
:global="data.isGlobal"
/>
<N8nTooltip v-if="isPrivateUnconnected" placement="top">
<template #content>
{{ locale.baseText('credentials.item.connect.tooltip') }}
</template>
<N8nButton
type="primary"
size="mini"
:loading="isConnecting"
data-test-id="credential-card-connect"
@click="onConnect"
>
{{ locale.baseText('credentials.item.connect') }}
</N8nButton>
</N8nTooltip>
<span
v-else-if="isPrivateConnected"
:class="$style.connectedLabel"
data-test-id="credential-card-connected"
>
<N8nIcon icon="circle-check" size="small" color="success" />
<N8nText size="small" color="success">
{{ locale.baseText('credentials.item.connected') }}
</N8nText>
</span>
<N8nActionToggle
data-test-id="credential-card-actions"
:actions="actions"
@ -256,6 +324,12 @@ function moveResource() {
cursor: default;
}
.connectedLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
}
.dynamicBadgeText {
display: inline-flex;
align-items: center;

View File

@ -2,10 +2,13 @@ import { createComponentRenderer } from '@/__tests__/render';
import { createTestProject } from '@/features/collaboration/projects/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import { useCredentialsStore } from '../credentials.store';
import type { ICredentialsResponse } from '../credentials.types';
import CredentialsView from './CredentialsView.vue';
import { useUIStore } from '@/app/stores/ui.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { mockedStore } from '@/__tests__/utils';
import { waitFor, within, fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { STORES } from '@n8n/stores';
import { CREDENTIAL_SELECT_MODAL_KEY } from '../credentials.constants';
import { VIEWS } from '@/app/constants';
@ -15,6 +18,16 @@ import { flushPromises } from '@vue/test-utils';
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
import * as projectsApi from '@/features/collaboration/projects/projects.api';
const mockAuthorize = vi.fn();
const mockIsOAuthCredentialType = vi.fn(() => true);
vi.mock('../composables/useCredentialOAuth', () => ({
useCredentialOAuth: () => ({
authorize: mockAuthorize,
isOAuthCredentialType: mockIsOAuthCredentialType,
}),
}));
vi.mock('@/app/composables/useGlobalEntityCreation', () => ({
useGlobalEntityCreation: () => ({
menu: [],
@ -80,8 +93,21 @@ describe('CredentialsView', () => {
afterEach(() => {
vi.clearAllMocks();
mockAuthorize.mockReset();
});
const enableDynamicCredentials = () => {
const settingsStore = mockedStore(useSettingsStore);
settingsStore.settings = {
...settingsStore.settings,
envFeatureFlags: {
N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: true,
},
activeModules: ['dynamic-credentials'],
} as unknown as typeof settingsStore.settings;
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
};
it('should render credentials', () => {
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
@ -392,4 +418,85 @@ describe('CredentialsView', () => {
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
});
});
describe('private credentials connect flow', () => {
const buildPrivateUnconnectedCredential = (
overrides: Partial<ICredentialsResponse> = {},
): ICredentialsResponse =>
({
id: 'cred-1',
name: 'Private OAuth cred',
type: 'oAuth2Api',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
isResolvable: true,
connectedByMe: false,
...overrides,
}) as ICredentialsResponse;
it('maps connectedByMe and isResolvable to the credential card', () => {
enableDynamicCredentials();
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [buildPrivateUnconnectedCredential()];
const { getByTestId } = renderComponent();
expect(getByTestId('credential-card-connect')).toBeInTheDocument();
expect(getByTestId('card-badge')).toBeInTheDocument();
});
it('renders the Connected label for connected private credentials', () => {
enableDynamicCredentials();
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
buildPrivateUnconnectedCredential({ connectedByMe: true }),
];
const { getByTestId, queryByTestId } = renderComponent();
expect(queryByTestId('credential-card-connect')).not.toBeInTheDocument();
expect(getByTestId('credential-card-connected')).toBeInTheDocument();
expect(getByTestId('card-badge')).toBeInTheDocument();
});
it('refetches credentials when the Connect button completes successfully', async () => {
enableDynamicCredentials();
const credentialsStore = mockedStore(useCredentialsStore);
const credential = buildPrivateUnconnectedCredential();
credentialsStore.allCredentials = [credential];
credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential);
mockAuthorize.mockResolvedValue(true);
const { getByTestId } = renderComponent();
await flushPromises();
credentialsStore.fetchAllCredentials.mockClear();
await userEvent.click(getByTestId('credential-card-connect'));
await flushPromises();
expect(mockAuthorize).toHaveBeenCalledWith(credential);
expect(credentialsStore.fetchAllCredentials).toHaveBeenCalled();
});
it('does not refetch credentials when the Connect flow is cancelled or fails', async () => {
enableDynamicCredentials();
const credentialsStore = mockedStore(useCredentialsStore);
const credential = buildPrivateUnconnectedCredential();
credentialsStore.allCredentials = [credential];
credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential);
mockAuthorize.mockResolvedValue(false);
const { getByTestId } = renderComponent();
await flushPromises();
credentialsStore.fetchAllCredentials.mockClear();
await userEvent.click(getByTestId('credential-card-connect'));
await flushPromises();
expect(mockAuthorize).toHaveBeenCalled();
expect(credentialsStore.fetchAllCredentials).not.toHaveBeenCalled();
});
});
});

View File

@ -100,6 +100,7 @@ const allCredentials = computed<Resource[]>(() =>
needsSetup: needsSetup(credential.data),
isGlobal: credential.isGlobal,
isResolvable: credential.isResolvable,
connectedByMe: credential.connectedByMe,
type: credential.type,
})),
);
@ -132,6 +133,14 @@ const setRouteCredentialId = (credentialId?: string) => {
void router.replace({ params: { credentialId }, query: route.query });
};
const refreshCredentials = () => {
void credentialsStore.fetchAllCredentials({
projectId: route?.params?.projectId as string | undefined,
includeScopes: true,
externalSecretsStore: filters.value.externalSecretsStore,
});
};
const addCredential = () => {
setRouteCredentialId('create');
telemetry.track('User clicked add cred button', {
@ -147,11 +156,7 @@ listenForModalChanges({
}
if (modalName === CREDENTIAL_EDIT_MODAL_KEY && credentialsStore.pendingOAuthRefresh) {
credentialsStore.pendingOAuthRefresh = false;
void credentialsStore.fetchAllCredentials({
projectId: route?.params?.projectId as string | undefined,
includeScopes: true,
externalSecretsStore: filters.value.externalSecretsStore,
});
refreshCredentials();
}
},
});
@ -248,11 +253,7 @@ const initialize = async () => {
credentialsStore.$onAction(({ name, after }) => {
if (name === 'createNewCredential' || name === 'updateCredential') {
after(() => {
void credentialsStore.fetchAllCredentials({
projectId: route?.params?.projectId as string | undefined,
includeScopes: true,
externalSecretsStore: filters.value.externalSecretsStore,
});
refreshCredentials();
});
}
});
@ -323,6 +324,7 @@ onMounted(() => {
:read-only="data.readOnly"
:needs-setup="data.needsSetup"
@click="setRouteCredentialId"
@connected="refreshCredentials"
/>
</template>
<template #filters="{ setKeyValue }">