mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 09:17:08 +02:00
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:
parent
f0ea4ed1f0
commit
3af0afcd28
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ export type CredentialsResource = BaseResource & {
|
|||
needsSetup: boolean;
|
||||
isGlobal?: boolean;
|
||||
isResolvable?: boolean;
|
||||
connectedByMe?: boolean;
|
||||
};
|
||||
|
||||
// Base resource types that are always available
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user