feat: Allow multi API creation via the UI (#12845)

This commit is contained in:
Ricardo Espinoza
2025-01-29 07:42:01 -05:00
committed by GitHub
parent c25c613a04
commit ad3250ceb0
33 changed files with 1036 additions and 207 deletions

View File

@@ -1484,14 +1484,6 @@ export interface IN8nPromptResponse {
updated: boolean;
}
export type ApiKey = {
id: string;
label: string;
apiKey: string;
createdAt: string;
updatedAt: string;
};
export type InputPanel = {
nodeName?: string;
run?: number;

View File

@@ -62,7 +62,13 @@ export const defaultSettings: FrontendSettings = {
disableSessionRecording: false,
enabled: false,
},
publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } },
publicApi: {
apiKeysPerUserLimit: 0,
enabled: false,
latestVersion: 0,
path: '',
swaggerUi: { enabled: false },
},
pushBackend: 'websocket',
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',

View File

@@ -1,12 +1,16 @@
import type { ApiKey, IRestApiContext } from '@/Interface';
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { CreateOrUpdateApiKeyRequestDto, ApiKey, ApiKeyWithRawValue } from '@n8n/api-types';
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
return await makeRestApiRequest(context, 'GET', '/api-keys');
}
export async function createApiKey(context: IRestApiContext): Promise<ApiKey> {
return await makeRestApiRequest(context, 'POST', '/api-keys');
export async function createApiKey(
context: IRestApiContext,
payload: CreateOrUpdateApiKeyRequestDto,
): Promise<ApiKeyWithRawValue> {
return await makeRestApiRequest(context, 'POST', '/api-keys', payload);
}
export async function deleteApiKey(
@@ -15,3 +19,11 @@ export async function deleteApiKey(
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'DELETE', `/api-keys/${id}`);
}
export async function updateApiKey(
context: IRestApiContext,
id: string,
payload: CreateOrUpdateApiKeyRequestDto,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'PATCH', `/api-keys/${id}`, payload);
}

View File

@@ -0,0 +1,115 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { ApiKey } from '@n8n/api-types';
import { DateTime } from 'luxon';
const API_KEY_ITEM_ACTIONS = {
EDIT: 'edit',
DELETE: 'delete',
};
const ACTION_LIST = [
{
label: 'Edit',
value: API_KEY_ITEM_ACTIONS.EDIT,
},
{
label: 'Delete',
value: API_KEY_ITEM_ACTIONS.DELETE,
},
];
const i18n = useI18n();
const cardActions = ref<HTMLDivElement | null>(null);
const props = defineProps<{
apiKey: ApiKey;
}>();
const emit = defineEmits<{
edit: [id: string];
delete: [id: string];
}>();
async function onAction(action: string) {
if (action === API_KEY_ITEM_ACTIONS.EDIT) {
emit('edit', props.apiKey.id);
} else if (action === API_KEY_ITEM_ACTIONS.DELETE) {
emit('delete', props.apiKey.id);
}
}
const getApiCreationTime = (apiKey: ApiKey): string => {
const timeAgo = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toRelative() ?? '';
return i18n.baseText('settings.api.creationTime', { interpolate: { time: timeAgo } });
};
</script>
<template>
<n8n-card :class="$style.cardLink" data-test-id="api-key-card" @click="onAction('edit')">
<template #header>
<div>
<n8n-heading tag="h2" bold :class="$style.cardHeading">
{{ apiKey.label }}
</n8n-heading>
<div :class="$style.cardDescription">
<n8n-text color="text-light" size="small">
<span>{{ getApiCreationTime(apiKey) }}</span>
</n8n-text>
</div>
</div>
<div v-if="apiKey.apiKey.includes('*')" :class="$style.cardApiKey">
<n8n-text color="text-light" size="small"> {{ apiKey.apiKey }}</n8n-text>
</div>
</template>
<template #append>
<div ref="cardActions" :class="$style.cardActions">
<n8n-action-toggle :actions="ACTION_LIST" theme="dark" @action="onAction" />
</div>
</template>
</n8n-card>
</template>
<style lang="scss" module>
.cardLink {
transition: box-shadow 0.3s ease;
cursor: pointer;
padding: 0 0 0 var(--spacing-s);
align-items: stretch;
&:hover {
box-shadow: 0 2px 8px rgba(#441c17, 0.1);
}
}
.cardHeading {
font-size: var(--font-size-s);
word-break: word-break;
padding: var(--spacing-s) 0 0 var(--spacing-s);
width: 200px;
}
.cardDescription {
min-height: 19px;
display: flex;
align-items: center;
padding: 0 0 var(--spacing-s) var(--spacing-s);
}
.cardActions {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0 var(--spacing-s) 0 0;
cursor: default;
}
.cardApiKey {
flex-grow: 1;
display: flex;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,119 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, STORES } from '@/constants';
import { cleanupAppModals, createAppModals, mockedStore, retry } from '@/__tests__/utils';
import ApiKeyEditModal from './ApiKeyCreateOrEditModal.vue';
import { fireEvent } from '@testing-library/vue';
import { useApiKeysStore } from '@/stores/apiKeys.store';
const renderComponent = createComponentRenderer(ApiKeyEditModal, {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: { open: true },
},
},
},
}),
});
const apiKeysStore = mockedStore(useApiKeysStore);
describe('ApiKeyCreateOrEditModal', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
test('should allow creating API key from modal', async () => {
apiKeysStore.createApiKey.mockResolvedValue({
id: '123',
label: 'new api key',
apiKey: '123456',
createdAt: new Date().toString(),
updatedAt: new Date().toString(),
rawApiKey: '***456',
});
const { getByText, getByPlaceholderText } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const inputLabel = getByPlaceholderText('e.g Internal Project');
const saveButton = getByText('Save');
expect(inputLabel).toBeInTheDocument();
expect(saveButton).toBeInTheDocument();
await fireEvent.update(inputLabel, 'new label');
await fireEvent.click(saveButton);
expect(getByText('***456')).toBeInTheDocument();
expect(getByText('API Key Created')).toBeInTheDocument();
expect(getByText('Done')).toBeInTheDocument();
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('You can find more details in')).toBeInTheDocument();
expect(getByText('the API documentation')).toBeInTheDocument();
expect(getByText('Click to copy')).toBeInTheDocument();
expect(getByText('new api key')).toBeInTheDocument();
});
test('should allow editing API key label', async () => {
apiKeysStore.apiKeys = [
{
id: '123',
label: 'new api key',
apiKey: '123**',
createdAt: new Date().toString(),
updatedAt: new Date().toString(),
},
];
apiKeysStore.updateApiKey.mockResolvedValue();
const { getByText, getByTestId } = renderComponent({
props: {
mode: 'edit',
activeId: '123',
},
});
await retry(() => expect(getByText('Edit API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const labelInput = getByTestId('api-key-label');
expect((labelInput as unknown as HTMLInputElement).value).toBe('new api key');
await fireEvent.update(labelInput, 'updated api key');
const editButton = getByText('Edit');
expect(editButton).toBeInTheDocument();
await fireEvent.click(editButton);
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', { label: 'updated api key' });
});
});

View File

@@ -0,0 +1,239 @@
<script lang="ts" setup>
import Modal from '@/components/Modal.vue';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, DOCS_DOMAIN } from '@/constants';
import { computed, onMounted, ref } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { createEventBus } from 'n8n-design-system/utils';
import { useI18n } from '@/composables/useI18n';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/root.store';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useApiKeysStore } from '@/stores/apiKeys.store';
import { useToast } from '@/composables/useToast';
import type { BaseTextKey } from '@/plugins/i18n';
import type { ApiKeyWithRawValue } from '@n8n/api-types';
const i18n = useI18n();
const { showError, showMessage } = useToast();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore;
const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore();
const { baseUrl } = useRootStore();
const documentTitle = useDocumentTitle();
const label = ref('');
const modalBus = createEventBus();
const newApiKey = ref<ApiKeyWithRawValue | null>(null);
const apiDocsURL = ref('');
const loading = ref(false);
const rawApiKey = ref('');
const inputRef = ref<HTMLTextAreaElement | null>(null);
const props = withDefaults(
defineProps<{
mode?: 'new' | 'edit';
activeId?: string;
}>(),
{
mode: 'new',
activeId: '',
},
);
onMounted(() => {
documentTitle.set(i18n.baseText('settings.api'));
setTimeout(() => {
inputRef.value?.focus();
});
if (props.mode === 'edit') {
label.value = apiKeysById[props.activeId]?.label ?? '';
}
apiDocsURL.value = isSwaggerUIEnabled
? `${baseUrl}${publicApiPath}/v${publicApiLatestVersion}/docs`
: `https://${DOCS_DOMAIN}/api/api-reference/`;
});
function onInput(value: string): void {
label.value = value;
}
async function onEdit() {
try {
loading.value = true;
await updateApiKey(props.activeId, { label: label.value });
showMessage({
type: 'success',
title: i18n.baseText('settings.api.update.toast'),
});
closeModal();
} catch (error) {
showError(error, i18n.baseText('settings.api.edit.error'));
} finally {
loading.value = false;
}
}
function closeModal() {
uiStore.closeModal(API_KEY_CREATE_OR_EDIT_MODAL_KEY);
}
const onSave = async () => {
if (!label.value) {
return;
}
try {
loading.value = true;
newApiKey.value = await createApiKey(label.value);
rawApiKey.value = newApiKey.value.rawApiKey;
showMessage({
type: 'success',
title: i18n.baseText('settings.api.create.toast'),
});
} catch (error) {
showError(error, i18n.baseText('settings.api.create.error'));
} finally {
loading.value = false;
}
};
const modalTitle = computed(() => {
let path = 'edit';
if (props.mode === 'new') {
if (newApiKey.value) {
path = 'created';
} else {
path = 'create';
}
}
return i18n.baseText(`settings.api.view.modal.title.${path}` as BaseTextKey);
});
</script>
<template>
<Modal
:title="modalTitle"
:event-bus="modalBus"
:name="API_KEY_CREATE_OR_EDIT_MODAL_KEY"
width="600px"
:lock-scroll="false"
:close-on-esc="true"
:close-on-click-outside="true"
:show-close="true"
>
<template #content>
<div>
<p v-if="newApiKey" class="mb-s">
<n8n-info-tip :bold="false">
<i18n-t keypath="settings.api.view.info" tag="span">
<template #apiAction>
<a
href="https://docs.n8n.io/api"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.api')"
/>
</template>
<template #webhookAction>
<a
href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.webhook/"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.webhook')"
/>
</template>
</i18n-t>
</n8n-info-tip>
</p>
<n8n-card v-if="newApiKey" class="mb-4xs" :class="$style.card">
<CopyInput
:label="newApiKey.label"
:value="newApiKey.rawApiKey"
:redact-value="true"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.api.view.copy.toast')"
:hint="i18n.baseText('settings.api.view.copy')"
/>
</n8n-card>
<div v-if="newApiKey" :class="$style.hint">
<n8n-text size="small">
{{
i18n.baseText(
`settings.api.view.${settingsStore.isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`,
)
}}
</n8n-text>
{{ ' ' }}
<n8n-link :to="apiDocsURL" :new-window="true" size="small">
{{
i18n.baseText(
`settings.api.view.${isSwaggerUIEnabled ? 'apiPlayground' : 'external-docs'}`,
)
}}
</n8n-link>
</div>
<N8nInputLabel
v-else
:label="i18n.baseText('settings.api.view.modal.form.label')"
color="text-dark"
>
<N8nInput
ref="inputRef"
required
:model-value="label"
type="text"
:placeholder="i18n.baseText('settings.api.view.modal.form.label.placeholder')"
:maxlength="50"
data-test-id="api-key-label"
@update:model-value="onInput"
/>
</N8nInputLabel>
</div>
</template>
<template #footer>
<div>
<N8nButton
v-if="mode === 'new' && !newApiKey"
float="right"
:loading="loading"
:label="i18n.baseText('settings.api.view.modal.save.button')"
@click="onSave"
/>
<N8nButton
v-else-if="mode === 'new'"
float="right"
:label="i18n.baseText('settings.api.view.modal.done.button')"
@click="closeModal"
/>
<N8nButton
v-else-if="mode === 'edit'"
float="right"
:label="i18n.baseText('settings.api.view.modal.edit.button')"
@click="onEdit"
/>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.card {
margin-bottom: 50px;
}
.notice {
margin: 0;
}
.hint {
color: var(--color-text-light);
margin-bottom: var(--spacing-s);
}
</style>

View File

@@ -31,6 +31,7 @@ const props = withDefaults(
closeOnClickModal?: boolean;
closeOnPressEscape?: boolean;
appendToBody?: boolean;
lockScroll?: boolean;
}>(),
{
title: '',
@@ -46,6 +47,7 @@ const props = withDefaults(
closeOnClickModal: true,
closeOnPressEscape: true,
appendToBody: false,
lockScroll: true,
},
);
@@ -143,6 +145,7 @@ function getCustomClass() {
:close-on-press-escape="closeOnPressEscape"
:style="styles"
:append-to="appendToBody ? undefined : appModalsId"
:lock-scroll="lockScroll"
:append-to-body="appendToBody"
:data-test-id="`${name}-modal`"
:modal-class="center ? $style.center : ''"

View File

@@ -7,6 +7,7 @@ import {
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
@@ -54,6 +55,7 @@ import WorkflowSettings from '@/components/WorkflowSettings.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue';
import ActivationModal from '@/components/ActivationModal.vue';
import ImportCurlModal from '@/components/ImportCurlModal.vue';
import ApiKeyCreateOrEditModal from '@/components/ApiKeyCreateOrEditModal.vue';
import MfaSetupModal from '@/components/MfaSetupModal.vue';
import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
@@ -83,6 +85,21 @@ import type { EventBus } from 'n8n-design-system';
<CredentialEdit :modal-name="modalName" :mode="mode" :active-id="activeId" />
</template>
</ModalRoot>
<ModalRoot :name="API_KEY_CREATE_OR_EDIT_MODAL_KEY">
<template
#default="{
modalName,
data: { mode, activeId },
}: {
modalName: string;
data: { mode: 'new' | 'edit'; activeId: string };
}"
>
<ApiKeyCreateOrEditModal :modal-name="modalName" :mode="mode" :active-id="activeId" />
</template>
</ModalRoot>
<ModalRoot :name="ABOUT_MODAL_KEY">
<AboutModal />
</ModalRoot>

View File

@@ -42,6 +42,7 @@ export const ABOUT_MODAL_KEY = 'about';
export const CHAT_EMBED_MODAL_KEY = 'chatEmbed';
export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword';
export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
export const API_KEY_CREATE_OR_EDIT_MODAL_KEY = 'createOrEditApiKey';
export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
export const DELETE_USER_MODAL_KEY = 'deleteUser';
export const INVITE_USER_MODAL_KEY = 'inviteUser';
@@ -660,6 +661,7 @@ export const enum STORES {
ASSISTANT = 'assistant',
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
PROJECTS = 'projects',
API_KEYS = 'apiKeys',
TEST_DEFINITION = 'testDefinition',
}

View File

@@ -1828,12 +1828,16 @@
"settings.api.create.description": "Control n8n programmatically using the <a href=\"https://docs.n8n.io/api\" target=\"_blank\">n8n API</a>",
"settings.api.create.button": "Create an API Key",
"settings.api.create.button.loading": "Creating API Key...",
"settings.api.create.error": "Creating the API Key failed.",
"settings.api.create.error": "API Key creation failed.",
"settings.api.edit.error": "API Key update failed.",
"settings.api.delete.title": "Delete this API Key?",
"settings.api.delete.description": "Any application using this API Key will no longer have access to n8n. This operation cannot be undone.",
"settings.api.delete.button": "Delete Forever",
"settings.api.delete.error": "Deleting the API Key failed.",
"settings.api.delete.toast": "API Key deleted",
"settings.api.create.toast": "API Key created",
"settings.api.update.toast": "API Key updated",
"settings.api.creationTime": "Created {time}",
"settings.api.view.copy.toast": "API Key copied to clipboard",
"settings.api.view.apiPlayground": "API Playground",
"settings.api.view.info": "Use your API Key to control n8n programmatically using the {apiAction}. But if you only want to trigger workflows, consider using the {webhookAction} instead.",
@@ -1844,6 +1848,14 @@
"settings.api.view.more-details": "You can find more details in",
"settings.api.view.external-docs": "the API documentation",
"settings.api.view.error": "Could not check if an api key already exists.",
"settings.api.view.modal.form.label": "Label",
"settings.api.view.modal.form.label.placeholder": "e.g Internal Project",
"settings.api.view.modal.title.created": "API Key Created",
"settings.api.view.modal.title.create": "Create API Key",
"settings.api.view.modal.title.edit": "Edit API Key",
"settings.api.view.modal.done.button": "Done",
"settings.api.view.modal.edit.button": "Edit",
"settings.api.view.modal.save.button": "Save",
"settings.version": "Version",
"settings.usageAndPlan.title": "Usage and plan",
"settings.usageAndPlan.description": "Youre on the {name} {type}",

View File

@@ -0,0 +1,67 @@
import { STORES } from '@/constants';
import { defineStore } from 'pinia';
import { useRootStore } from '@/stores/root.store';
import * as publicApiApi from '@/api/api-keys';
import { computed, ref } from 'vue';
import { useSettingsStore } from './settings.store';
import type { ApiKey } from '@n8n/api-types';
export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
const apiKeys = ref<ApiKey[]>([]);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const apiKeysSortByCreationDate = computed(() =>
apiKeys.value.sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
);
const apiKeysById = computed(() => {
return apiKeys.value.reduce(
(acc, apiKey) => {
acc[apiKey.id] = apiKey;
return acc;
},
{} as Record<string, ApiKey>,
);
});
const canAddMoreApiKeys = computed(
() => apiKeys.value.length < settingsStore.api.apiKeysPerUserLimit,
);
const getAndCacheApiKeys = async () => {
if (apiKeys.value.length) return apiKeys.value;
apiKeys.value = await publicApiApi.getApiKeys(rootStore.restApiContext);
return apiKeys.value;
};
const createApiKey = async (label: string) => {
const newApiKey = await publicApiApi.createApiKey(rootStore.restApiContext, { label });
const { rawApiKey, ...rest } = newApiKey;
apiKeys.value.push(rest);
return newApiKey;
};
const deleteApiKey = async (id: string) => {
await publicApiApi.deleteApiKey(rootStore.restApiContext, id);
apiKeys.value = apiKeys.value.filter((apiKey) => apiKey.id !== id);
};
const updateApiKey = async (id: string, data: { label: string }) => {
await publicApiApi.updateApiKey(rootStore.restApiContext, id, data);
apiKeysById.value[id].label = data.label;
};
return {
getAndCacheApiKeys,
createApiKey,
deleteApiKey,
updateApiKey,
apiKeysSortByCreationDate,
apiKeysById,
apiKeys,
canAddMoreApiKeys,
};
});

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
import Bowser from 'bowser';
import type { IUserManagementSettings, FrontendSettings } from '@n8n/api-types';
import * as publicApiApi from '@/api/api-keys';
import * as eventsApi from '@/api/events';
import * as ldapApi from '@/api/ldap';
import * as settingsApi from '@/api/settings';
@@ -32,6 +31,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
});
const templatesEndpointHealthy = ref(false);
const api = ref({
apiKeysPerUserLimit: 0,
enabled: false,
latestVersion: 0,
path: '/',
@@ -321,21 +321,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
templatesEndpointHealthy.value = true;
};
const getApiKeys = async () => {
const rootStore = useRootStore();
return await publicApiApi.getApiKeys(rootStore.restApiContext);
};
const createApiKey = async () => {
const rootStore = useRootStore();
return await publicApiApi.createApiKey(rootStore.restApiContext);
};
const deleteApiKey = async (id: string) => {
const rootStore = useRootStore();
await publicApiApi.deleteApiKey(rootStore.restApiContext, id);
};
const getLdapConfig = async () => {
const rootStore = useRootStore();
return await ldapApi.getLdapConfig(rootStore.restApiContext);
@@ -438,9 +423,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
updateLdapConfig,
runLdapSync,
getTimezones,
createApiKey,
getApiKeys,
deleteApiKey,
testTemplatesEndpoint,
submitContactInfo,
disableTemplates,

View File

@@ -36,6 +36,7 @@ import {
NEW_ASSISTANT_SESSION_MODAL,
PROMPT_MFA_CODE_MODAL_KEY,
COMMUNITY_PLUS_ENROLLMENT_MODAL,
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
} from '@/constants';
import type {
INodeUi,
@@ -143,6 +144,13 @@ export const useUIStore = defineStore(STORES.UI, () => {
open: false,
data: undefined,
},
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: {
open: false,
data: {
activeId: null,
mode: '',
},
},
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: false,
mode: '',

View File

@@ -0,0 +1,105 @@
import { fireEvent, screen } from '@testing-library/vue';
import { useSettingsStore } from '@/stores/settings.store';
import { renderComponent } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import SettingsApiView from './SettingsApiView.vue';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useApiKeysStore } from '@/stores/apiKeys.store';
setActivePinia(createTestingPinia());
const settingsStore = mockedStore(useSettingsStore);
const cloudStore = mockedStore(useCloudPlanStore);
const apiKeysStore = mockedStore(useApiKeysStore);
describe('SettingsApiView', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('if user public api is not enabled and user is trialing it should show upgrade call to action', () => {
settingsStore.isPublicApiEnabled = false;
cloudStore.userIsTrialing = true;
renderComponent(SettingsApiView);
expect(screen.getByText('Upgrade to use API')).toBeInTheDocument();
expect(
screen.getByText(
'To prevent abuse, we limit API access to your workspace during your trial. If this is hindering your evaluation of n8n, please contact',
),
).toBeInTheDocument();
expect(screen.getByText('support@n8n.io')).toBeInTheDocument();
expect(screen.getByText('Upgrade plan')).toBeInTheDocument();
});
it('if user public api enabled and no API keys in account, it should create API key CTA', () => {
settingsStore.isPublicApiEnabled = true;
cloudStore.userIsTrialing = false;
apiKeysStore.apiKeys = [];
renderComponent(SettingsApiView);
expect(screen.getByText('Create an API Key')).toBeInTheDocument();
expect(screen.getByText('Control n8n programmatically using the')).toBeInTheDocument();
expect(screen.getByText('n8n API')).toBeInTheDocument();
});
it('if user public api enabled and there are API Keys in account, they should be rendered', async () => {
settingsStore.isPublicApiEnabled = true;
cloudStore.userIsTrialing = false;
apiKeysStore.apiKeys = [
{
id: '1',
label: 'test-key-1',
createdAt: new Date().toString(),
updatedAt: new Date().toString(),
apiKey: '****Atcr',
},
];
renderComponent(SettingsApiView);
expect(screen.getByText(/Created \d+ seconds ago/)).toBeInTheDocument();
expect(screen.getByText('****Atcr')).toBeInTheDocument();
expect(screen.getByText('test-key-1')).toBeInTheDocument();
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('should show delete warning when trying to delete an API key', async () => {
settingsStore.isPublicApiEnabled = true;
cloudStore.userIsTrialing = false;
apiKeysStore.apiKeys = [
{
id: '1',
label: 'test-key-1',
createdAt: new Date().toString(),
updatedAt: new Date().toString(),
apiKey: '****Atcr',
},
];
renderComponent(SettingsApiView);
expect(screen.getByText(/Created \d+ seconds ago/)).toBeInTheDocument();
expect(screen.getByText('****Atcr')).toBeInTheDocument();
expect(screen.getByText('test-key-1')).toBeInTheDocument();
await fireEvent.click(screen.getByTestId('action-toggle'));
await fireEvent.click(screen.getByTestId('action-delete'));
expect(screen.getByText('Delete this API Key?')).toBeInTheDocument();
expect(
screen.getByText(
'Any application using this API Key will no longer have access to n8n. This operation cannot be undone.',
),
).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
});

View File

@@ -1,22 +1,22 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import type { ApiKey } from '@/Interface';
import { onMounted, ref } from 'vue';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import CopyInput from '@/components/CopyInput.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/root.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { DOCS_DOMAIN, MODAL_CONFIRM } from '@/constants';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, MODAL_CONFIRM } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useUIStore } from '@/stores/ui.store';
import { useApiKeysStore } from '@/stores/apiKeys.store';
import { storeToRefs } from 'pinia';
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const cloudPlanStore = useCloudPlanStore();
const { baseUrl } = useRootStore();
const { showError, showMessage } = useToast();
const { confirm } = useMessage();
@@ -26,33 +26,45 @@ const { goToUpgrade } = usePageRedirectionHelper();
const telemetry = useTelemetry();
const loading = ref(false);
const mounted = ref(false);
const apiKeys = ref<ApiKey[]>([]);
const apiDocsURL = ref('');
const apiKeysStore = useApiKeysStore();
const { getAndCacheApiKeys, deleteApiKey } = apiKeysStore;
const { apiKeysSortByCreationDate } = storeToRefs(apiKeysStore);
const { isPublicApiEnabled, isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } =
settingsStore;
const { isPublicApiEnabled } = settingsStore;
const isRedactedApiKey = computed((): boolean => {
if (!apiKeys.value) return false;
return apiKeys.value[0].apiKey.includes('*');
});
const onCreateApiKey = async () => {
telemetry.track('User clicked create API key button');
onMounted(() => {
uiStore.openModalWithData({
name: API_KEY_CREATE_OR_EDIT_MODAL_KEY,
data: { mode: 'new' },
});
};
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.api'));
if (!isPublicApiEnabled) return;
void getApiKeys();
apiDocsURL.value = isSwaggerUIEnabled
? `${baseUrl}${publicApiPath}/v${publicApiLatestVersion}/docs`
: `https://${DOCS_DOMAIN}/api/api-reference/`;
await getApiKeys();
});
function onUpgrade() {
void goToUpgrade('settings-n8n-api', 'upgrade-api', 'redirect');
}
async function showDeleteModal() {
async function getApiKeys() {
try {
loading.value = true;
await getAndCacheApiKeys();
} catch (error) {
showError(error, i18n.baseText('settings.api.view.error'));
} finally {
loading.value = false;
}
}
async function onDelete(id: string) {
const confirmed = await confirm(
i18n.baseText('settings.api.delete.description'),
i18n.baseText('settings.api.delete.title'),
@@ -61,52 +73,27 @@ async function showDeleteModal() {
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (confirmed === MODAL_CONFIRM) {
await deleteApiKey();
try {
await deleteApiKey(id);
showMessage({
title: i18n.baseText('settings.api.delete.toast'),
type: 'success',
});
} catch (e) {
showError(e, i18n.baseText('settings.api.delete.error'));
} finally {
telemetry.track('User clicked delete API key button');
}
}
}
async function getApiKeys() {
try {
apiKeys.value = await settingsStore.getApiKeys();
} catch (error) {
showError(error, i18n.baseText('settings.api.view.error'));
} finally {
mounted.value = true;
}
}
async function createApiKey() {
loading.value = true;
try {
const newApiKey = await settingsStore.createApiKey();
apiKeys.value.push(newApiKey);
} catch (error) {
showError(error, i18n.baseText('settings.api.create.error'));
} finally {
loading.value = false;
telemetry.track('User clicked create API key button');
}
}
async function deleteApiKey() {
try {
await settingsStore.deleteApiKey(apiKeys.value[0].id);
showMessage({
title: i18n.baseText('settings.api.delete.toast'),
type: 'success',
});
apiKeys.value = [];
} catch (error) {
showError(error, i18n.baseText('settings.api.delete.error'));
} finally {
telemetry.track('User clicked delete API key button');
}
}
function onCopy() {
telemetry.track('User clicked copy API key button');
function onEdit(id: string) {
uiStore.openModalWithData({
name: API_KEY_CREATE_OR_EDIT_MODAL_KEY,
data: { mode: 'edit', activeId: id },
});
}
</script>
@@ -120,62 +107,29 @@ function onCopy() {
</span>
</n8n-heading>
</div>
<template v-if="apiKeysSortByCreationDate.length">
<el-row
v-for="apiKey in apiKeysSortByCreationDate"
:key="apiKey.id"
:gutter="10"
:class="$style.destinationItem"
>
<el-col>
<ApiKeyCard :api-key="apiKey" @delete="onDelete" @edit="onEdit" />
</el-col>
</el-row>
<div v-if="apiKeys.length">
<p class="mb-s">
<n8n-info-tip :bold="false">
<i18n-t keypath="settings.api.view.info" tag="span">
<template #apiAction>
<a
href="https://docs.n8n.io/api"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.api')"
/>
</template>
<template #webhookAction>
<a
href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.webhook/"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.webhook')"
/>
</template>
</i18n-t>
</n8n-info-tip>
</p>
<n8n-card class="mb-4xs" :class="$style.card">
<span :class="$style.delete">
<n8n-link :bold="true" @click="showDeleteModal">
{{ i18n.baseText('generic.delete') }}
</n8n-link>
</span>
<div>
<CopyInput
:label="apiKeys[0].label"
:value="apiKeys[0].apiKey"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.api.view.copy.toast')"
:redact-value="true"
:disable-copy="isRedactedApiKey"
:hint="!isRedactedApiKey ? i18n.baseText('settings.api.view.copy') : ''"
@copy="onCopy"
/>
</div>
</n8n-card>
<div :class="$style.hint">
<n8n-text size="small">
{{ i18n.baseText(`settings.api.view.${isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`) }}
</n8n-text>
{{ ' ' }}
<n8n-link :to="apiDocsURL" :new-window="true" size="small">
{{
i18n.baseText(
`settings.api.view.${isSwaggerUIEnabled ? 'apiPlayground' : 'external-docs'}`,
)
}}
</n8n-link>
<div class="mt-m text-right">
<n8n-button
size="large"
:disabled="!apiKeysStore.canAddMoreApiKeys"
@click="onCreateApiKey"
>
{{ i18n.baseText('settings.api.create.button') }}
</n8n-button>
</div>
</div>
</template>
<n8n-action-box
v-else-if="!isPublicApiEnabled && cloudPlanStore.userIsTrialing"
data-test-id="public-api-upgrade-cta"
@@ -185,27 +139,22 @@ function onCopy() {
@click:button="onUpgrade"
/>
<n8n-action-box
v-else-if="mounted && !cloudPlanStore.state.loadingPlan"
v-if="isPublicApiEnabled && !apiKeysSortByCreationDate.length"
:button-text="
i18n.baseText(loading ? 'settings.api.create.button.loading' : 'settings.api.create.button')
"
:description="i18n.baseText('settings.api.create.description')"
@click:button="createApiKey"
@click:button="onCreateApiKey"
/>
</div>
</template>
<style lang="scss" module>
.container {
> * {
margin-bottom: var(--spacing-2xl);
}
}
.header {
display: flex;
align-items: center;
white-space: nowrap;
margin-bottom: var(--spacing-2xl);
*:first-child {
flex-grow: 1;
@@ -216,6 +165,10 @@ function onCopy() {
position: relative;
}
.destinationItem {
margin-bottom: var(--spacing-2xs);
}
.delete {
position: absolute;
display: inline-block;