Files
n8n-enterprise-unlocked/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue

1235 lines
34 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import type {
ICredentialsDecryptedResponse,
ICredentialsResponse,
IUpdateInformation,
} from '@/Interface';
import CredentialIcon from '@/components/CredentialIcon.vue';
import type {
CredentialInformation,
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
INode,
INodeParameters,
INodeProperties,
ITelemetryTrackProperties,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import CredentialConfig from '@/components/CredentialEdit/CredentialConfig.vue';
import CredentialInfo from '@/components/CredentialEdit/CredentialInfo.vue';
import CredentialSharing from '@/components/CredentialEdit/CredentialSharing.ee.vue';
import InlineNameEdit from '@/components/InlineNameEdit.vue';
import Modal from '@/components/Modal.vue';
import SaveButton from '@/components/SaveButton.vue';
import { useMessage } from '@/composables/useMessage';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useToast } from '@/composables/useToast';
import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
import { getResourcePermissions } from '@/permissions';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Project, ProjectSharingData } from '@/types/projects.types';
import { assert } from '@n8n/utils/assert';
import type { IMenuItem } from '@n8n/design-system';
import { createEventBus } from '@n8n/utils/event-bus';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useProjectsStore } from '@/stores/projects.store';
import { isExpression, isTestableExpression } from '@/utils/expressions';
import {
getNodeAuthOptions,
getNodeCredentialForSelectedAuthType,
updateNodeAuthType,
} from '@/utils/nodeTypesUtils';
import { isCredentialModalState, isValidCredentialResponse } from '@/utils/typeGuards';
type Props = {
modalName: string;
activeId?: string;
mode?: 'new' | 'edit';
};
const props = withDefaults(defineProps<Props>(), { mode: 'new', activeId: undefined });
const credentialsStore = useCredentialsStore();
const ndvStore = useNDVStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const projectsStore = useProjectsStore();
const nodeHelpers = useNodeHelpers();
const externalHooks = useExternalHooks();
const toast = useToast();
const message = useMessage();
const i18n = useI18n();
const telemetry = useTelemetry();
const activeTab = ref('connection');
const authError = ref('');
const credentialId = ref('');
const credentialName = ref('');
const selectedCredential = ref('');
const credentialData = ref<ICredentialDataDecryptedObject>({});
const currentCredential = ref<ICredentialsResponse | ICredentialsDecryptedResponse | null>(null);
const modalBus = ref(createEventBus());
const isDeleting = ref(false);
const isSaving = ref(false);
const isTesting = ref(false);
const hasUnsavedChanges = ref(false);
const isSaved = ref(false);
const loading = ref(false);
const showValidationWarning = ref(false);
const testedSuccessfully = ref(false);
const isRetesting = ref(false);
const hasUserSpecifiedName = ref(false);
const isSharedWithChanged = ref(false);
const requiredCredentials = ref(false); // Are credentials required or optional for the node
const contentRef = ref<HTMLDivElement>();
const activeNodeType = computed(() => {
const activeNode = ndvStore.activeNode;
if (activeNode) {
return nodeTypesStore.getNodeType(activeNode.type, activeNode.typeVersion);
}
return null;
});
const selectedCredentialType = computed(() => {
if (props.mode !== 'new') {
return null;
}
// If there is already selected type, use it
if (selectedCredential.value !== '') {
return credentialsStore.getCredentialTypeByName(selectedCredential.value) ?? null;
} else if (requiredCredentials.value) {
// Otherwise, use credential type that corresponds to the first auth option in the node definition
const nodeAuthOptions = getNodeAuthOptions(activeNodeType.value);
// But only if there is zero or one auth options available
if (nodeAuthOptions.length > 0 && activeNodeType.value?.credentials) {
return getNodeCredentialForSelectedAuthType(activeNodeType.value, nodeAuthOptions[0].value);
} else {
return activeNodeType.value?.credentials ? activeNodeType.value.credentials[0] : null;
}
}
return null;
});
const credentialType = computed(() => {
if (!credentialTypeName.value) {
return null;
}
const type = credentialsStore.getCredentialTypeByName(credentialTypeName.value);
if (!type) {
return null;
}
return {
...type,
properties: getCredentialProperties(credentialTypeName.value),
};
});
const credentialTypeName = computed(() => {
if (props.mode === 'edit') {
if (currentCredential.value) {
return currentCredential.value.type;
}
return null;
}
if (selectedCredentialType.value) {
return selectedCredentialType.value.name;
}
return `${props.activeId}`;
});
const isEditingManagedCredential = computed(() => {
if (!props.activeId) return false;
return credentialsStore.getCredentialById(props.activeId)?.isManaged ?? false;
});
const isCredentialTestable = computed(() => {
if (isOAuthType.value || !requiredPropertiesFilled.value) {
return false;
}
const hasUntestableExpressions = Object.values(credentialData.value).reduce(
(accu: boolean, value: CredentialInformation) =>
accu || (typeof value === 'string' && isExpression(value) && !isTestableExpression(value)),
false,
);
if (hasUntestableExpressions) {
return false;
}
const nodesThatCanTest = nodesWithAccess.value.filter((node) => {
if (node.credentials) {
// Returns a list of nodes that can test this credentials
const eligibleTesters = node.credentials.filter((credential) => {
return credential.name === credentialTypeName.value && credential.testedBy;
});
// If we have any node that can test, return true.
return !!eligibleTesters.length;
}
return false;
});
return !!nodesThatCanTest.length || (!!credentialType.value && !!credentialType.value.test);
});
const nodesWithAccess = computed(() => {
if (credentialTypeName.value) {
return credentialsStore.getNodesWithAccess(credentialTypeName.value);
}
return [];
});
const parentTypes = computed(() => {
if (credentialTypeName.value) {
return getParentTypes(credentialTypeName.value);
}
return [];
});
const isOAuthType = computed(() => {
return (
!!credentialTypeName.value &&
(((credentialTypeName.value === 'oAuth2Api' || parentTypes.value.includes('oAuth2Api')) &&
(credentialData.value.grantType === 'authorizationCode' ||
credentialData.value.grantType === 'pkce')) ||
credentialTypeName.value === 'oAuth1Api' ||
parentTypes.value.includes('oAuth1Api'))
);
});
const allOAuth2BasePropertiesOverridden = computed(() => {
if (credentialType.value?.__overwrittenProperties) {
return (
credentialType.value.__overwrittenProperties.includes('clientId') &&
credentialType.value.__overwrittenProperties.includes('clientSecret')
);
}
return false;
});
const isOAuthConnected = computed(() => isOAuthType.value && !!credentialData.value.oauthTokenData);
const credentialProperties = computed(() => {
const type = credentialType.value;
if (!type) {
return [];
}
const properties = type.properties.filter((propertyData: INodeProperties) => {
if (!displayCredentialParameter(propertyData)) {
return false;
}
return (
!type.__overwrittenProperties || !type.__overwrittenProperties.includes(propertyData.name)
);
});
/**
* If after all credentials overrides are applied only "notice"
* properties are left, do not return them. This will avoid
* showing notices that refer to a property that was overridden.
*/
if (properties.every((p) => p.type === 'notice')) {
return [];
}
return properties;
});
const requiredPropertiesFilled = computed(() => {
for (const property of credentialProperties.value) {
if (property.required !== true) {
continue;
}
const credentialProperty = credentialData.value[property.name];
if (property.type === 'string' && !credentialProperty) {
return false;
}
if (property.type === 'number') {
const containsExpression =
typeof credentialProperty === 'string' && credentialProperty.startsWith('=');
if (typeof credentialProperty !== 'number' && !containsExpression) {
return false;
}
}
}
return true;
});
const credentialPermissions = computed(() => {
return getResourcePermissions(
(currentCredential.value as ICredentialsResponse)?.scopes ?? homeProject.value?.scopes,
).credential;
});
const sidebarItems = computed(() => {
const menuItems: IMenuItem[] = [
{
id: 'connection',
label: i18n.baseText('credentialEdit.credentialEdit.connection'),
position: 'top',
},
{
id: 'sharing',
label: i18n.baseText('credentialEdit.credentialEdit.sharing'),
position: 'top',
},
{
id: 'details',
label: i18n.baseText('credentialEdit.credentialEdit.details'),
position: 'top',
},
];
return menuItems;
});
const defaultCredentialTypeName = computed(() => {
let defaultName = credentialTypeName.value;
if (!defaultName || defaultName === 'null') {
if (activeNodeType.value?.credentials && activeNodeType.value.credentials.length > 0) {
defaultName = activeNodeType.value.credentials[0].name;
}
}
return defaultName ?? '';
});
const showSaveButton = computed(() => {
return (
(props.mode === 'new' || hasUnsavedChanges.value || isSaved.value) &&
(credentialPermissions.value.create ?? credentialPermissions.value.update)
);
});
const showSharingContent = computed(() => activeTab.value === 'sharing' && !!credentialType.value);
const homeProject = computed(() => {
const { currentProject, personalProject } = projectsStore;
return currentProject ?? personalProject;
});
onMounted(async () => {
requiredCredentials.value =
isCredentialModalState(uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]) &&
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].showAuthSelector === true;
if (props.mode === 'new' && credentialTypeName.value) {
credentialName.value = await credentialsStore.getNewCredentialName({
credentialTypeName: defaultCredentialTypeName.value,
});
credentialData.value = {
...credentialData.value,
...(homeProject.value ? { homeProject: homeProject.value } : {}),
};
} else {
await loadCurrentCredential();
}
if (credentialType.value) {
for (const property of credentialType.value.properties) {
if (
!credentialData.value.hasOwnProperty(property.name) &&
!credentialType.value.__overwrittenProperties?.includes(property.name)
) {
credentialData.value = {
...credentialData.value,
[property.name]: property.default as CredentialInformation,
};
}
}
}
await externalHooks.run('credentialsEdit.credentialModalOpened', {
credentialType: credentialTypeName.value,
isEditingCredential: props.mode === 'edit',
activeNode: ndvStore.activeNode,
});
setTimeout(async () => {
if (credentialId.value) {
if (!requiredPropertiesFilled.value && credentialPermissions.value.update) {
// sharees can't see properties, so this check would always fail for them
// if the credential contains required fields.
showValidationWarning.value = true;
} else {
await retestCredential();
}
}
}, 0);
loading.value = false;
});
async function beforeClose() {
let keepEditing = false;
if (hasUnsavedChanges.value) {
const displayName = credentialType.value ? credentialType.value.displayName : '';
const confirmAction = await message.confirm(
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.message', {
interpolate: { credentialDisplayName: displayName },
}),
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.headline'),
{
cancelButtonText: i18n.baseText(
'credentialEdit.credentialEdit.confirmMessage.beforeClose1.cancelButtonText',
),
confirmButtonText: i18n.baseText(
'credentialEdit.credentialEdit.confirmMessage.beforeClose1.confirmButtonText',
),
},
);
keepEditing = confirmAction === MODAL_CONFIRM;
} else if (credentialPermissions.value.update && isOAuthType.value && !isOAuthConnected.value) {
const confirmAction = await message.confirm(
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.message'),
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.headline'),
{
cancelButtonText: i18n.baseText(
'credentialEdit.credentialEdit.confirmMessage.beforeClose2.cancelButtonText',
),
confirmButtonText: i18n.baseText(
'credentialEdit.credentialEdit.confirmMessage.beforeClose2.confirmButtonText',
),
},
);
keepEditing = confirmAction === MODAL_CONFIRM;
}
if (!keepEditing) {
uiStore.activeCredentialType = null;
return true;
} else if (!requiredPropertiesFilled.value) {
showValidationWarning.value = true;
scrollToTop();
} else if (isOAuthType.value) {
scrollToBottom();
}
return false;
}
function displayCredentialParameter(parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (parameter.displayOptions?.hideOnCloud && settingsStore.isCloudDeployment) {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return nodeHelpers.displayParameter(credentialData.value as INodeParameters, parameter, '', null);
}
function getCredentialProperties(name: string): INodeProperties[] {
const credentialTypeData = credentialsStore.getCredentialTypeByName(name);
if (!credentialTypeData) {
return [];
}
if (credentialTypeData.extends === undefined) {
return credentialTypeData.properties;
}
const combineProperties = [] as INodeProperties[];
for (const credentialsTypeName of credentialTypeData.extends) {
const mergeCredentialProperties = getCredentialProperties(credentialsTypeName);
NodeHelpers.mergeNodeProperties(combineProperties, mergeCredentialProperties);
}
// The properties defined on the parent credentials take precedence
NodeHelpers.mergeNodeProperties(combineProperties, credentialTypeData.properties);
return combineProperties;
}
async function loadCurrentCredential() {
credentialId.value = props.activeId ?? '';
try {
const currentCredentials = await credentialsStore.getCredentialData({
id: credentialId.value,
});
if (!currentCredentials) {
throw new Error(
i18n.baseText('credentialEdit.credentialEdit.couldNotFindCredentialWithId') +
':' +
credentialId.value,
);
}
currentCredential.value = currentCredentials;
credentialData.value = (currentCredentials.data as ICredentialDataDecryptedObject) || {};
if (currentCredentials.sharedWithProjects) {
credentialData.value = {
...credentialData.value,
sharedWithProjects: currentCredentials.sharedWithProjects,
};
}
if (currentCredentials.homeProject) {
credentialData.value = {
...credentialData.value,
homeProject: currentCredentials.homeProject,
};
}
credentialName.value = currentCredentials.name;
} catch (error) {
toast.showError(
error,
i18n.baseText('credentialEdit.credentialEdit.showError.loadCredential.title'),
);
closeDialog();
return;
}
}
function onTabSelect(tab: string) {
activeTab.value = tab;
const credType: string = credentialType.value ? credentialType.value.name : '';
const activeNode: INode | null = ndvStore.activeNode;
telemetry.track('User viewed credential tab', {
credential_type: credType,
node_type: activeNode ? activeNode.type : null,
tab,
workflow_id: workflowsStore.workflowId,
credential_id: credentialId.value,
sharing_enabled: EnterpriseEditionFeature.Sharing,
});
}
function onChangeSharedWith(sharedWithProjects: ProjectSharingData[]) {
credentialData.value = {
...credentialData.value,
sharedWithProjects,
};
isSharedWithChanged.value = true;
hasUnsavedChanges.value = true;
}
function onDataChange({ name, value }: IUpdateInformation) {
// skip update if new value matches the current
if (credentialData.value[name] === value) return;
hasUnsavedChanges.value = true;
const { oauthTokenData, ...credData } = credentialData.value;
credentialData.value = {
...credData,
[name]: value as CredentialInformation,
};
}
function closeDialog() {
modalBus.value.emit('close');
}
function getParentTypes(name: string): string[] {
const type = credentialsStore.getCredentialTypeByName(name);
if (type?.extends === undefined) {
return [];
}
const types: string[] = [];
for (const typeName of type.extends) {
types.push(typeName);
types.push.apply(types, getParentTypes(typeName)); // eslint-disable-line prefer-spread
}
return types;
}
function onNameEdit(text: string) {
hasUnsavedChanges.value = true;
hasUserSpecifiedName.value = true;
credentialName.value = text;
}
function scrollToTop() {
setTimeout(() => {
if (contentRef.value) {
contentRef.value.scrollTop = 0;
}
}, 0);
}
function scrollToBottom() {
setTimeout(() => {
if (contentRef.value) {
contentRef.value.scrollTop = contentRef.value.scrollHeight;
}
}, 0);
}
async function retestCredential() {
if (isEditingManagedCredential.value) {
return;
}
if (!isCredentialTestable.value || !credentialTypeName.value) {
authError.value = '';
testedSuccessfully.value = false;
return;
}
const { ownedBy, sharedWithProjects, ...otherCredData } = credentialData.value;
const details: ICredentialsDecrypted = {
id: credentialId.value,
name: credentialName.value,
type: credentialTypeName.value,
data: otherCredData,
};
isRetesting.value = true;
await testCredential(details);
isRetesting.value = false;
}
async function testCredential(credentialDetails: ICredentialsDecrypted) {
const result = await credentialsStore.testCredential(credentialDetails);
if (result.status === 'Error') {
authError.value = result.message;
testedSuccessfully.value = false;
} else {
authError.value = '';
testedSuccessfully.value = true;
}
scrollToTop();
}
function usesExternalSecrets(data: Record<string, unknown>): boolean {
return Object.entries(data).some(
([, value]) => typeof value !== 'object' && /=.*\{\{[^}]*\$secrets\.[^}]+}}.*/.test(`${value}`),
);
}
async function saveCredential(): Promise<ICredentialsResponse | null> {
if (!requiredPropertiesFilled.value) {
showValidationWarning.value = true;
scrollToTop();
} else {
showValidationWarning.value = false;
}
isSaving.value = true;
// Save only the none default data
assert(credentialType.value);
const data = NodeHelpers.getNodeParameters(
credentialType.value.properties,
credentialData.value as INodeParameters,
false,
false,
null,
);
assert(credentialTypeName.value);
const credentialDetails: ICredentialsDecrypted = {
id: credentialId.value,
name: credentialName.value,
type: credentialTypeName.value,
data: data as unknown as ICredentialDataDecryptedObject,
};
if (
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing] &&
credentialData.value.sharedWithProjects
) {
credentialDetails.sharedWithProjects = credentialData.value
.sharedWithProjects as ProjectSharingData[];
}
if (credentialData.value.homeProject) {
credentialDetails.homeProject = credentialData.value.homeProject as ProjectSharingData;
}
let credential: ICredentialsResponse | null = null;
const isNewCredential = props.mode === 'new' && !credentialId.value;
if (isNewCredential) {
credential = await createCredential(credentialDetails, projectsStore.currentProject);
} else {
if (settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) {
credentialDetails.sharedWithProjects = credentialData.value
.sharedWithProjects as ProjectSharingData[];
}
credential = await updateCredential(credentialDetails);
}
isSaving.value = false;
if (credential) {
credentialId.value = credential.id;
currentCredential.value = credential;
if (isCredentialTestable.value) {
isTesting.value = true;
// Add the full data including defaults for testing
credentialDetails.data = credentialData.value;
credentialDetails.id = credentialId.value;
await testCredential(credentialDetails);
isTesting.value = false;
} else {
authError.value = '';
testedSuccessfully.value = false;
}
const trackProperties: ITelemetryTrackProperties = {
credential_type: credentialDetails.type,
workflow_id: workflowsStore.workflowId,
credential_id: credential.id,
is_complete: !!requiredPropertiesFilled.value,
is_new: isNewCredential,
uses_external_secrets: usesExternalSecrets(credentialDetails.data ?? {}),
};
if (isOAuthType.value) {
trackProperties.is_valid = !!isOAuthConnected.value;
} else if (isCredentialTestable.value) {
trackProperties.is_valid = !!testedSuccessfully.value;
}
if (ndvStore.activeNode) {
trackProperties.node_type = ndvStore.activeNode.type;
}
if (authError.value && authError.value !== '') {
trackProperties.authError = authError.value;
}
/**
* For non-OAuth credentials we track saving on clicking the `Save` button, but for
* OAuth credentials we track saving at the end of the flow (BroastcastChannel event)
* so that the `is_valid` property is correct.
*/
if (!isOAuthType.value) {
telemetry.track('User saved credentials', trackProperties);
}
await externalHooks.run('credentialEdit.saveCredential', trackProperties);
}
return credential;
}
const createToastMessagingForNewCredentials = (
credentialDetails: ICredentialsDecrypted,
project?: Project | null,
) => {
let toastTitle = i18n.baseText('credentials.create.personal.toast.title');
let toastText = '';
if (!credentialDetails.sharedWithProjects) {
toastText = i18n.baseText('credentials.create.personal.toast.text');
}
if (
projectsStore.currentProject &&
projectsStore.currentProject.id !== projectsStore.personalProject?.id
) {
toastTitle = i18n.baseText('credentials.create.project.toast.title', {
interpolate: { projectName: project?.name ?? '' },
});
toastText = i18n.baseText('credentials.create.project.toast.text', {
interpolate: { projectName: project?.name ?? '' },
});
}
return {
title: toastTitle,
message: toastText,
};
};
async function createCredential(
credentialDetails: ICredentialsDecrypted,
project?: Project | null,
): Promise<ICredentialsResponse | null> {
let credential;
try {
credential = await credentialsStore.createNewCredential(credentialDetails, project?.id);
hasUnsavedChanges.value = false;
const { title, message } = createToastMessagingForNewCredentials(credentialDetails, project);
toast.showMessage({
title,
message,
type: 'success',
});
} catch (error) {
toast.showError(
error,
i18n.baseText('credentialEdit.credentialEdit.showError.createCredential.title'),
);
return null;
}
await externalHooks.run('credential.saved', {
credential_type: credentialDetails.type,
credential_id: credential.id,
is_new: true,
});
telemetry.track('User created credentials', {
credential_type: credentialDetails.type,
credential_id: credential.id,
workflow_id: workflowsStore.workflowId,
});
return credential;
}
async function updateCredential(
credentialDetails: ICredentialsDecrypted,
): Promise<ICredentialsResponse | null> {
let credential: ICredentialsResponse | null = null;
try {
if (credentialPermissions.value.update) {
credential = await credentialsStore.updateCredential({
id: credentialId.value,
data: credentialDetails,
});
}
if (
credentialPermissions.value.share &&
isSharedWithChanged.value &&
credentialDetails.sharedWithProjects
) {
credential = await credentialsStore.setCredentialSharedWith({
credentialId: credentialDetails.id,
sharedWithProjects: credentialDetails.sharedWithProjects,
});
isSharedWithChanged.value = false;
}
hasUnsavedChanges.value = false;
isSaved.value = true;
if (credential) {
await externalHooks.run('credential.saved', {
credential_type: credentialDetails.type,
credential_id: credential.id,
is_new: false,
});
}
} catch (error) {
toast.showError(
error,
i18n.baseText('credentialEdit.credentialEdit.showError.updateCredential.title'),
);
return null;
}
// Now that the credentials changed check if any nodes use credentials
// which have now a different name
nodeHelpers.updateNodesCredentialsIssues();
return credential;
}
async function deleteCredential() {
if (!currentCredential.value) {
return;
}
const savedCredentialName = currentCredential.value.name;
const deleteConfirmed = await message.confirm(
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.message', {
interpolate: { savedCredentialName },
}),
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.headline'),
{
confirmButtonText: i18n.baseText(
'credentialEdit.credentialEdit.confirmMessage.deleteCredential.confirmButtonText',
),
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
try {
isDeleting.value = true;
await credentialsStore.deleteCredential({ id: credentialId.value });
hasUnsavedChanges.value = false;
isSaved.value = true;
} catch (error) {
toast.showError(
error,
i18n.baseText('credentialEdit.credentialEdit.showError.deleteCredential.title'),
);
isDeleting.value = false;
return;
}
isDeleting.value = false;
// Now that the credentials were removed check if any nodes used them
nodeHelpers.updateNodesCredentialsIssues();
credentialData.value = {};
toast.showMessage({
title: i18n.baseText('credentialEdit.credentialEdit.showMessage.title'),
type: 'success',
});
closeDialog();
}
async function oAuthCredentialAuthorize() {
let url;
const credential = await saveCredential();
if (!credential) {
return;
}
const types = parentTypes.value;
try {
const credData = { id: credential.id, ...credentialData.value };
if (credentialTypeName.value === 'oAuth2Api' || types.includes('oAuth2Api')) {
if (isValidCredentialResponse(credData)) {
url = await credentialsStore.oAuth2Authorize(credData);
}
} else if (credentialTypeName.value === 'oAuth1Api' || types.includes('oAuth1Api')) {
if (isValidCredentialResponse(credData)) {
url = await credentialsStore.oAuth1Authorize(credData);
}
}
} catch (error) {
toast.showError(
error,
i18n.baseText('credentialEdit.credentialEdit.showError.generateAuthorizationUrl.title'),
i18n.baseText('credentialEdit.credentialEdit.showError.generateAuthorizationUrl.message'),
);
return;
}
const params =
'scrollbars=no,resizable=yes,status=no,titlebar=noe,location=no,toolbar=no,menubar=no,width=500,height=700';
const oauthPopup = window.open(url, 'OAuth Authorization', params);
credentialData.value = {
...credentialData.value,
oauthTokenData: null as unknown as CredentialInformation,
};
const oauthChannel = new BroadcastChannel('oauth-callback');
const receiveMessage = (event: MessageEvent) => {
const successfullyConnected = event.data === 'success';
const trackProperties: ITelemetryTrackProperties = {
credential_type: credentialTypeName.value,
workflow_id: workflowsStore.workflowId,
credential_id: credentialId.value,
is_complete: !!requiredPropertiesFilled.value,
is_new: props.mode === 'new' && !credentialId.value,
is_valid: successfullyConnected,
uses_external_secrets: usesExternalSecrets(credentialData.value),
};
if (ndvStore.activeNode) {
trackProperties.node_type = ndvStore.activeNode.type;
}
telemetry.track('User saved credentials', trackProperties);
if (successfullyConnected) {
oauthChannel.removeEventListener('message', receiveMessage);
// Set some kind of data that status changes.
// As data does not get displayed directly it does not matter what data.
credentialData.value = {
...credentialData.value,
oauthTokenData: {} as CredentialInformation,
};
// Close the window
if (oauthPopup) {
oauthPopup.close();
}
}
};
oauthChannel.addEventListener('message', receiveMessage);
}
async function onAuthTypeChanged(type: string): Promise<void> {
if (!activeNodeType.value?.credentials) {
return;
}
const credentialsForType = getNodeCredentialForSelectedAuthType(activeNodeType.value, type);
if (credentialsForType) {
selectedCredential.value = credentialsForType.name;
uiStore.activeCredentialType = credentialsForType.name;
resetCredentialData();
// Update current node auth type so credentials dropdown can be displayed properly
updateNodeAuthType(ndvStore.activeNode, type);
// Also update credential name but only if the default name is still used
if (hasUnsavedChanges.value && !hasUserSpecifiedName.value) {
const newDefaultName = await credentialsStore.getNewCredentialName({
credentialTypeName: defaultCredentialTypeName.value,
});
credentialName.value = newDefaultName;
}
}
}
function resetCredentialData(): void {
if (!credentialType.value) {
return;
}
for (const property of credentialType.value.properties) {
if (!credentialType.value.__overwrittenProperties?.includes(property.name)) {
credentialData.value = {
...credentialData.value,
[property.name]: property.default as CredentialInformation,
};
}
}
const { currentProject, personalProject } = projectsStore;
const scopes = currentProject?.scopes ?? personalProject?.scopes ?? [];
const homeProject = currentProject ?? personalProject ?? {};
credentialData.value = {
...credentialData.value,
scopes: scopes as unknown as CredentialInformation,
homeProject,
};
}
</script>
<template>
<Modal
:name="modalName"
:custom-class="$style.credentialModal"
:event-bus="modalBus"
:loading="loading"
:before-close="beforeClose"
width="70%"
height="80%"
>
<template #header>
<div :class="$style.header">
<div :class="$style.credInfo">
<div :class="$style.credIcon">
<CredentialIcon :credential-type-name="defaultCredentialTypeName" />
</div>
<InlineNameEdit
:model-value="credentialName"
:subtitle="credentialType ? credentialType.displayName : ''"
:readonly="
!credentialPermissions.update || !credentialType || isEditingManagedCredential
"
type="Credential"
data-test-id="credential-name"
@update:model-value="onNameEdit"
/>
</div>
<div :class="$style.credActions">
<n8n-icon-button
v-if="currentCredential && credentialPermissions.delete"
:title="i18n.baseText('credentialEdit.credentialEdit.delete')"
icon="trash"
type="tertiary"
:disabled="isSaving"
:loading="isDeleting"
data-test-id="credential-delete-button"
@click="deleteCredential"
/>
<SaveButton
v-if="showSaveButton"
:saved="!hasUnsavedChanges && !isTesting && !!credentialId"
:is-saving="isSaving || isTesting"
:saving-label="
isTesting
? i18n.baseText('credentialEdit.credentialEdit.testing')
: i18n.baseText('credentialEdit.credentialEdit.saving')
"
data-test-id="credential-save-button"
@click="saveCredential"
/>
</div>
</div>
</template>
<template #content>
<div :class="$style.container" data-test-id="credential-edit-dialog">
<div v-if="!isEditingManagedCredential" :class="$style.sidebar">
<n8n-menu
mode="tabs"
:items="sidebarItems"
:transparent-background="true"
@select="onTabSelect"
></n8n-menu>
</div>
<div
v-if="activeTab === 'connection' && credentialType"
ref="contentRef"
:class="$style.mainContent"
>
<CredentialConfig
:credential-type="credentialType"
:credential-properties="credentialProperties"
:credential-data="credentialData"
:credential-id="credentialId"
:is-managed="isEditingManagedCredential"
:show-validation-warning="showValidationWarning"
:auth-error="authError"
:tested-successfully="testedSuccessfully"
:is-o-auth-type="isOAuthType"
:is-o-auth-connected="isOAuthConnected"
:is-retesting="isRetesting"
:parent-types="parentTypes"
:required-properties-filled="requiredPropertiesFilled"
:credential-permissions="credentialPermissions"
:all-o-auth2-base-properties-overridden="allOAuth2BasePropertiesOverridden"
:mode="mode"
:selected-credential="selectedCredential"
:show-auth-type-selector="requiredCredentials"
@update="onDataChange"
@oauth="oAuthCredentialAuthorize"
@retest="retestCredential"
@scroll-to-top="scrollToTop"
@auth-type-changed="onAuthTypeChanged"
/>
</div>
<div v-else-if="showSharingContent" :class="$style.mainContent">
<CredentialSharing
:credential="currentCredential"
:credential-data="credentialData"
:credential-id="credentialId"
:credential-permissions="credentialPermissions"
:modal-bus="modalBus"
@update:model-value="onChangeSharedWith"
/>
</div>
<div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent">
<CredentialInfo :current-credential="currentCredential" />
</div>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.credentialModal {
--dialog-max-width: 1200px;
--dialog-close-top: 31px;
--dialog-max-height: 750px;
:global(.el-dialog__header) {
padding-bottom: 0;
border-bottom: var(--border-base);
}
:global(.el-dialog__body) {
padding-top: var(--spacing-l);
position: relative;
}
}
.mainContent {
flex: 1;
overflow: auto;
padding-bottom: 100px;
}
.sidebar {
max-width: 170px;
min-width: 170px;
margin-right: var(--spacing-l);
flex-grow: 1;
ul {
padding: 0 !important;
}
}
.header {
display: flex;
}
.container {
display: flex;
height: 100%;
}
.credInfo {
display: flex;
align-items: center;
flex-direction: row;
flex-grow: 1;
margin-bottom: var(--spacing-l);
}
.credActions {
display: flex;
flex-direction: row;
align-items: center;
margin-right: var(--spacing-xl);
margin-bottom: var(--spacing-l);
> * {
margin-left: var(--spacing-2xs);
}
}
.credIcon {
display: flex;
align-items: center;
margin-right: var(--spacing-xs);
}
</style>