mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 21:37:32 -08:00
feat(editor): Add route for create / edit / share credentials (#11134)
Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
parent
51606cb279
commit
5697de4429
|
@ -21,6 +21,10 @@ const CREDENTIAL_LIST_ITEM_ACTIONS = {
|
||||||
MOVE: 'move',
|
MOVE: 'move',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [credentialId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
data: ICredentialsResponse;
|
data: ICredentialsResponse;
|
||||||
|
@ -83,7 +87,7 @@ const formattedCreatedAtDate = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function onClick() {
|
function onClick() {
|
||||||
uiStore.openExistingCredential(props.data.id);
|
emit('click', props.data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAction(action: string) {
|
async function onAction(action: string) {
|
||||||
|
@ -131,7 +135,7 @@ function moveResource() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n8n-card :class="$style.cardLink" @click="onClick">
|
<n8n-card :class="$style.cardLink" @click.stop="onClick">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<CredentialIcon :credential-type-name="credentialType?.name ?? ''" />
|
<CredentialIcon :credential-type-name="credentialType?.name ?? ''" />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -18,7 +18,8 @@ const commonChildRoutes: RouteRecordRaw[] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'credentials',
|
path: 'credentials/:credentialId?',
|
||||||
|
props: true,
|
||||||
components: {
|
components: {
|
||||||
default: CredentialsView,
|
default: CredentialsView,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
|
|
|
@ -1,84 +1,138 @@
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import type { Scope } from '@n8n/permissions';
|
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
|
||||||
import CredentialsView from '@/views/CredentialsView.vue';
|
import CredentialsView from '@/views/CredentialsView.vue';
|
||||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { waitFor, within, fireEvent } from '@testing-library/vue';
|
||||||
|
import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import type { Project } from '@/types/projects.types';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
vi.mock('@/components/layouts/ResourcesListLayout.vue', async (importOriginal) => {
|
vi.mock('vue-router', async () => {
|
||||||
const original = await importOriginal<typeof ResourcesListLayout>();
|
const actual = await vi.importActual('vue-router');
|
||||||
|
const push = vi.fn();
|
||||||
|
const replace = vi.fn();
|
||||||
return {
|
return {
|
||||||
default: {
|
...actual,
|
||||||
...original.default,
|
// your mocked methods
|
||||||
render: vi.fn(),
|
useRouter: () => ({
|
||||||
setup: vi.fn(),
|
push,
|
||||||
},
|
replace,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
[STORES.SETTINGS]: {
|
||||||
|
settings: { enterprise: { variables: true, projects: { team: { limit: -1 } } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CredentialsView);
|
const renderComponent = createComponentRenderer(CredentialsView);
|
||||||
|
let router: ReturnType<typeof useRouter>;
|
||||||
|
|
||||||
describe('CredentialsView', () => {
|
describe('CredentialsView', () => {
|
||||||
describe('with fake stores', () => {
|
beforeEach(() => {
|
||||||
let credentialsStore: ReturnType<typeof useCredentialsStore>;
|
createTestingPinia({ initialState });
|
||||||
|
router = useRouter();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
afterEach(() => {
|
||||||
createTestingPinia();
|
vi.clearAllMocks();
|
||||||
credentialsStore = useCredentialsStore();
|
});
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
it('should render credentials', () => {
|
||||||
vi.resetAllMocks();
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
});
|
credentialsStore.allCredentials = [
|
||||||
it('should have ResourcesListLayout render with necessary keys from credential object', () => {
|
{
|
||||||
const homeProject: ProjectSharingData = {
|
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'test',
|
name: 'test',
|
||||||
type: 'personal',
|
type: 'test',
|
||||||
createdAt: '2021-05-05T00:00:00Z',
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
updatedAt: '2021-05-05T00:00:00Z',
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
};
|
},
|
||||||
const scopes: Scope[] = ['credential:move', 'credential:delete'];
|
];
|
||||||
const sharedWithProjects: ProjectSharingData[] = [
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
{
|
projectsStore.isProjectHome = false;
|
||||||
id: '2',
|
const { getByTestId } = renderComponent();
|
||||||
name: 'test 2',
|
expect(getByTestId('resources-list-item')).toBeVisible();
|
||||||
type: 'personal',
|
});
|
||||||
createdAt: '2021-05-05T00:00:00Z',
|
|
||||||
updatedAt: '2021-05-05T00:00:00Z',
|
it('should disable cards based on permissions', () => {
|
||||||
},
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
];
|
credentialsStore.allCredentials = [
|
||||||
vi.spyOn(credentialsStore, 'allCredentials', 'get').mockReturnValue([
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'test',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:update'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'test2',
|
||||||
|
type: 'test2',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.isProjectHome = false;
|
||||||
|
const { getAllByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const items = getAllByTestId('resources-list-item');
|
||||||
|
expect(items.length).toBe(2);
|
||||||
|
|
||||||
|
expect(within(items[1]).getByText('Read only')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create credential', () => {
|
||||||
|
it('should show modal based on route param', async () => {
|
||||||
|
const uiStore = mockedStore(useUIStore);
|
||||||
|
renderComponent({ props: { credentialId: 'create' } });
|
||||||
|
expect(uiStore.openModal).toHaveBeenCalledWith(CREDENTIAL_SELECT_MODAL_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update credentialId route param to create', async () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.isProjectHome = false;
|
||||||
|
projectsStore.currentProject = { scopes: ['credential:create'] } as Project;
|
||||||
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
|
credentialsStore.allCredentials = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'test',
|
name: 'test',
|
||||||
type: 'test',
|
type: 'test',
|
||||||
createdAt: '2021-05-05T00:00:00Z',
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
updatedAt: '2021-05-05T00:00:00Z',
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
homeProject,
|
|
||||||
scopes,
|
|
||||||
sharedWithProjects,
|
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
renderComponent();
|
const { getByTestId } = renderComponent();
|
||||||
expect(ResourcesListLayout.setup).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
await userEvent.click(getByTestId('resources-list-add'));
|
||||||
resources: [
|
await waitFor(() =>
|
||||||
expect.objectContaining({
|
expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: 'create' } }),
|
||||||
type: 'test',
|
|
||||||
homeProject,
|
|
||||||
scopes,
|
|
||||||
sharedWithProjects,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should disable cards based on permissions', () => {
|
describe('open existing credential', () => {
|
||||||
vi.spyOn(credentialsStore, 'allCredentials', 'get').mockReturnValue([
|
it('should show modal based on route param', async () => {
|
||||||
|
const uiStore = mockedStore(useUIStore);
|
||||||
|
renderComponent({ props: { credentialId: 'credential-id' } });
|
||||||
|
expect(uiStore.openExistingCredential).toHaveBeenCalledWith('credential-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update credentialId route param when opened', async () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.isProjectHome = false;
|
||||||
|
projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
|
||||||
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
|
credentialsStore.allCredentials = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'test',
|
name: 'test',
|
||||||
|
@ -87,28 +141,15 @@ describe('CredentialsView', () => {
|
||||||
updatedAt: '2021-05-05T00:00:00Z',
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
scopes: ['credential:update'],
|
scopes: ['credential:update'],
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
id: '2',
|
const { getByTestId } = renderComponent();
|
||||||
name: 'test2',
|
|
||||||
type: 'test2',
|
|
||||||
createdAt: '2021-05-05T00:00:00Z',
|
|
||||||
updatedAt: '2021-05-05T00:00:00Z',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
renderComponent();
|
/**
|
||||||
expect(ResourcesListLayout.setup).toHaveBeenCalledWith(
|
* userEvent DOES NOT work here
|
||||||
expect.objectContaining({
|
*/
|
||||||
resources: [
|
await fireEvent.click(getByTestId('resources-list-item'));
|
||||||
expect.objectContaining({
|
await waitFor(() =>
|
||||||
readOnly: false,
|
expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: '1' } }),
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
readOnly: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
|
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
|
||||||
import type { IResource } from '@/components/layouts/ResourcesListLayout.vue';
|
import type { IResource } from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import CredentialCard from '@/components/CredentialCard.vue';
|
import CredentialCard from '@/components/CredentialCard.vue';
|
||||||
import type { ICredentialType } from 'n8n-workflow';
|
import type { ICredentialType } from 'n8n-workflow';
|
||||||
import { CREDENTIAL_SELECT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
|
import {
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
CREDENTIAL_SELECT_MODAL_KEY,
|
||||||
|
CREDENTIAL_EDIT_MODAL_KEY,
|
||||||
|
EnterpriseEditionFeature,
|
||||||
|
} from '@/constants';
|
||||||
|
import { useUIStore, listenForModalChanges } from '@/stores/ui.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
||||||
|
@ -22,6 +26,10 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { N8nButton, N8nInputLabel, N8nSelect, N8nOption } from 'n8n-design-system';
|
import { N8nButton, N8nInputLabel, N8nSelect, N8nOption } from 'n8n-design-system';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
credentialId?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
@ -31,6 +39,7 @@ const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const documentTitle = useDocumentTitle();
|
const documentTitle = useDocumentTitle();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
@ -77,13 +86,43 @@ const projectPermissions = computed(() =>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setRouteCredentialId = (credentialId?: string) => {
|
||||||
|
void router.replace({ params: { credentialId } });
|
||||||
|
};
|
||||||
|
|
||||||
const addCredential = () => {
|
const addCredential = () => {
|
||||||
uiStore.openModal(CREDENTIAL_SELECT_MODAL_KEY);
|
setRouteCredentialId('create');
|
||||||
telemetry.track('User clicked add cred button', {
|
telemetry.track('User clicked add cred button', {
|
||||||
source: 'Creds list',
|
source: 'Creds list',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
listenForModalChanges({
|
||||||
|
store: uiStore,
|
||||||
|
onModalClosed(modalName) {
|
||||||
|
if ([CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY].includes(modalName as string)) {
|
||||||
|
void router.replace({ params: { credentialId: '' } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.credentialId,
|
||||||
|
(id) => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
if (id === 'create') {
|
||||||
|
uiStore.openModal(CREDENTIAL_SELECT_MODAL_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uiStore.openExistingCredential(id);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const onFilter = (
|
const onFilter = (
|
||||||
resource: ICredentialsResponse,
|
resource: ICredentialsResponse,
|
||||||
filtersToApply: { type: string[]; search: string },
|
filtersToApply: { type: string[]; search: string },
|
||||||
|
@ -172,6 +211,7 @@ onMounted(() => {
|
||||||
class="mb-2xs"
|
class="mb-2xs"
|
||||||
:data="data"
|
:data="data"
|
||||||
:read-only="data.readOnly"
|
:read-only="data.readOnly"
|
||||||
|
@click="setRouteCredentialId"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #filters="{ setKeyValue }">
|
<template #filters="{ setKeyValue }">
|
||||||
|
|
Loading…
Reference in a new issue