diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 6036b3b2fbc..f1b16726c1b 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -13,8 +13,10 @@ import { CREDENTIAL_ERRORS, CredentialDataError, Credentials, type ErrorReporter import { CREDENTIAL_BLANKING_VALUE, CREDENTIAL_EMPTY_VALUE, + jsonParse, type ICredentialDataDecryptedObject, type ICredentialType, + type INodeProperties, } from 'n8n-workflow'; import { mockExistingCredential } from './credentials.test-data'; @@ -485,6 +487,11 @@ describe('CredentialsService', () => { const httpCustomAuthCredType = { properties: [ + { + name: 'json', + type: 'json', + typeOptions: { redactJsonLeaves: true }, + }, { name: 'otherField', type: 'string', @@ -499,7 +506,7 @@ describe('CredentialsService', () => { const redactedData = service.redact(decryptedData, credential); expect(redactedData).toEqual({ - json: CREDENTIAL_BLANKING_VALUE, + json: JSON.stringify({ key: '***' }, null, 2), otherField: 'not-redacted', }); }); @@ -518,6 +525,11 @@ describe('CredentialsService', () => { const httpCustomAuthCredType = { properties: [ + { + name: 'json', + type: 'json', + typeOptions: { redactJsonLeaves: true }, + }, { name: 'otherField', type: 'string', @@ -537,6 +549,156 @@ describe('CredentialsService', () => { }); }); + describe('httpCustomAuth redactJsonLeaves (via service.redact)', () => { + const makeHttpCustomAuthCredential = () => + mock({ id: '123', name: 'Test', type: 'httpCustomAuth' }); + + const makeCredType = () => + ({ + properties: [{ name: 'json', type: 'json', typeOptions: { redactJsonLeaves: true } }], + }) as unknown as ICredentialType; + + it('should redact string leaf values', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { json: '{"token": "secret", "key": "abc"}' }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual(JSON.stringify({ token: '***', key: '***' }, null, 2)); + }); + + it('should redact numeric leaf values', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { json: '{"port": 8080, "timeout": 30}' }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual(JSON.stringify({ port: '***', timeout: '***' }, null, 2)); + }); + + it('should redact boolean leaf values', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { json: '{"enabled": true, "debug": false}' }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual(JSON.stringify({ enabled: '***', debug: '***' }, null, 2)); + }); + + it('should redact leaves of nested objects recursively', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { + json: '{"headers": {"Authorization": "Bearer token", "X-Api-Key": "key"}, "url": "https://example.com"}', + }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual( + JSON.stringify( + { headers: { Authorization: '***', 'X-Api-Key': '***' }, url: '***' }, + null, + 2, + ), + ); + }); + + it('should redact leaves inside arrays', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { json: '{"scopes": ["read", "write"], "tags": ["prod"]}' }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual( + JSON.stringify({ scopes: ['***', '***'], tags: ['***'] }, null, 2), + ); + }); + + it('should redact leaves in deeply mixed nesting (array of objects)', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { json: '{"rules": [{"key": "a", "val": 1}, {"key": "b", "val": 2}]}' }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual( + JSON.stringify( + { + rules: [ + { key: '***', val: '***' }, + { key: '***', val: '***' }, + ], + }, + null, + 2, + ), + ); + }); + + it('should return json unchanged when it is not parseable', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact({ json: 'not-valid-json' }, makeHttpCustomAuthCredential()); + + expect(result.json).toEqual('not-valid-json'); + }); + + it('should redact leaves in a deeply nested object', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { json: '{"a":{"b":{"c":{"d":"deep-secret"}}}}' }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual(JSON.stringify({ a: { b: { c: { d: '***' } } } }, null, 2)); + }); + + it('should redact leaves inside an array nested inside a deep object', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact( + { + json: '{"auth":{"headers":[{"name":"Authorization","value":"Bearer s"},{"name":"X-Key","value":"abc"}]}}', + }, + makeHttpCustomAuthCredential(), + ); + + expect(result.json).toEqual( + JSON.stringify( + { + auth: { + headers: [ + { name: '***', value: '***' }, + { name: '***', value: '***' }, + ], + }, + }, + null, + 2, + ), + ); + }); + + it('should redact null leaf values', () => { + credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType()); + + const result = service.redact({ json: '{"token":null}' }, makeHttpCustomAuthCredential()); + + expect(result.json).toEqual(JSON.stringify({ token: '***' }, null, 2)); + }); + }); + it('should not redact json field for non-httpCustomAuth credential types', () => { const credential = mock({ id: '123', @@ -2064,6 +2226,182 @@ describe('CredentialsService', () => { }); }); + describe('unredact (httpCustomAuth JSON shape merging)', () => { + const s = (v: unknown) => JSON.stringify(v); + const p = (v: unknown) => jsonParse(String(v)); + const jsonLeafProps = [ + { name: 'json', type: 'json', typeOptions: { redactJsonLeaves: true } }, + ] as unknown as INodeProperties[]; + + it('should restore all *** leaves from saved JSON when all are redacted', () => { + const saved = { token: 'real-token', key: 'real-key' }; + const result = service.unredact( + { json: s({ token: '***', key: '***' }) }, + { json: s(saved) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual(saved); + }); + + it('should keep new value for changed leaf and restore *** leaf from saved', () => { + const result = service.unredact( + { json: s({ token: 'new-token', key: '***' }) }, + { json: s({ token: 'old-token', key: 'old-key' }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ token: 'new-token', key: 'old-key' }); + }); + + it('should merge *** leaves recursively in nested objects', () => { + const result = service.unredact( + { json: s({ headers: { Authorization: 'Bearer new', 'X-Api-Key': '***' } }) }, + { json: s({ headers: { Authorization: 'Bearer secret', 'X-Api-Key': 'key123' } }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ + headers: { Authorization: 'Bearer new', 'X-Api-Key': 'key123' }, + }); + }); + + it('should merge *** elements in arrays by position', () => { + const result = service.unredact( + { json: s({ scopes: ['***', 'new-write', '***'] }) }, + { json: s({ scopes: ['read', 'write', 'admin'] }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ scopes: ['read', 'new-write', 'admin'] }); + }); + + it('should save all leaves as-is when none are redacted', () => { + const result = service.unredact( + { json: s({ token: 'completely-new', extra: 'new-field' }) }, + { json: s({ token: 'old-token' }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ token: 'completely-new', extra: 'new-field' }); + }); + + it('should leave json unchanged when it is not valid JSON', () => { + const result = service.unredact( + { json: 'not-valid-json' }, + { json: s({ token: 'saved' }) }, + jsonLeafProps, + ); + expect(result.json).toEqual('not-valid-json'); + }); + + it('should not apply JSON leaf merge when no props are given', () => { + const result = service.unredact( + { json: s({ token: '***' }) }, + { json: s({ token: 'saved' }) }, + ); + // *** is not a standard sentinel so it stays as-is + expect(p(result.json)).toEqual({ token: '***' }); + }); + + it('should not apply JSON leaf merge when props have no redactJsonLeaves field', () => { + const result = service.unredact( + { json: s({ token: '***' }) }, + { json: s({ token: 'saved' }) }, + [{ name: 'json', type: 'json' }] as INodeProperties[], + ); + expect(p(result.json)).toEqual({ token: '***' }); + }); + + it('should restore *** leaves in a deeply nested object', () => { + const result = service.unredact( + { json: s({ a: { b: { c: { d: '***' } } } }) }, + { json: s({ a: { b: { c: { d: 'deep-secret' } } } }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ a: { b: { c: { d: 'deep-secret' } } } }); + }); + + it('should restore *** leaves inside arrays nested inside deep objects', () => { + const result = service.unredact( + { + json: s({ + auth: { + headers: [ + { name: '***', value: '***' }, + { name: '***', value: 'new-value' }, + ], + }, + }), + }, + { + json: s({ + auth: { + headers: [ + { name: 'Authorization', value: 'Bearer secret' }, + { name: 'X-Api-Key', value: 'key123' }, + ], + }, + }), + }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ + auth: { + headers: [ + { name: 'Authorization', value: 'Bearer secret' }, + { name: 'X-Api-Key', value: 'new-value' }, + ], + }, + }); + }); + + it('should drop a key the user removed from the JSON', () => { + // When the user removes a key, it simply won't be in newVal — it is not re-added from saved + const result = service.unredact( + { json: s({ token: '***' }) }, + { json: s({ token: 'secret', removedKey: 'data' }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ token: 'secret' }); + expect(p(result.json)).not.toHaveProperty('removedKey'); + }); + + it('should save a new key the user added to the JSON', () => { + const result = service.unredact( + { json: s({ token: '***', newHeader: 'X-Custom-Value' }) }, + { json: s({ token: 'secret' }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ token: 'secret', newHeader: 'X-Custom-Value' }); + }); + + it('should use the new scalar value when the user changes a key from object to scalar', () => { + // Type change: saved was an object, user replaced it with a plain string — new value wins + const result = service.unredact( + { json: s({ auth: 'new-plain-string' }) }, + { json: s({ auth: { token: 'secret', key: 'abc' } }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ auth: 'new-plain-string' }); + }); + + it('should preserve the new array when the user changes a key from object to array', () => { + // Type mismatch (array vs object): user made a structural change — new value is preserved as-is + const result = service.unredact( + { json: s({ auth: ['new-item-1', 'new-item-2'] }) }, + { json: s({ auth: { token: 'secret' } }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ auth: ['new-item-1', 'new-item-2'] }); + }); + + it('should preserve the new object when the user changes a key from array to object (even with *** sentinels)', () => { + // Type mismatch (object vs array): structural change — new object is preserved as-is, *** unresolved + const result = service.unredact( + { json: s({ data: { key: '***' } }) }, + { json: s({ data: ['secret1', 'secret2'] }) }, + jsonLeafProps, + ); + expect(p(result.json)).toEqual({ data: { key: '***' } }); + }); + }); + describe('checkCredentialData', () => { const testProjectId = 'test-project-id'; diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index db558bcf2bc..2833b93a649 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -169,6 +169,7 @@ export class CredentialsController { mergedCredentials.data = this.credentialsService.unredact( mergedCredentials.data, decryptedData, + this.credentialsService.getCredentialTypeProperties(storedCredential.type), ); } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 8616981f31c..1db9eb20b12 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -34,19 +34,11 @@ import { displayParameter, isExpression, isINodePropertyCollection, + jsonParse, + jsonStringify, NodeHelpers, } from 'n8n-workflow'; -import { - CredentialDependencyService, - type CredentialDependencyFilter, -} from './credential-dependency.service'; -import { CredentialsFinderService } from './credentials-finder.service'; -import { - validateAccessToReferencedSecretProviders, - validateExternalSecretsPermissions, -} from './validation'; - import { CredentialTypes } from '@/credential-types'; import { createCredentialsFromCredentialsEntity, CredentialsHelper } from '@/credentials-helper'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -64,6 +56,19 @@ import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; +import { + CredentialDependencyService, + type CredentialDependencyFilter, +} from './credential-dependency.service'; +import { CredentialsFinderService } from './credentials-finder.service'; +import { + validateAccessToReferencedSecretProviders, + validateExternalSecretsPermissions, +} from './validation'; + +/** Sentinel placed at every leaf of a redacted httpCustomAuth JSON shape */ +const CUSTOM_AUTH_JSON_REDACTED_VALUE = '***'; + export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: false }; @@ -609,7 +614,11 @@ export class CredentialsService { const mergedData = deepCopy(data); if (mergedData.data) { - mergedData.data = this.unredact(mergedData.data, decryptedData); + mergedData.data = this.unredact( + mergedData.data, + decryptedData, + this.getCredentialTypeProperties(existingCredential.type), + ); } // This saves us a merge but requires some type casting. These @@ -826,15 +835,13 @@ export class CredentialsService { return props; }; const properties = getExtendedProps(credType); - const redacted = this.redactValues(copiedData, properties, credential.type); - return redacted; + return this.redactValues(copiedData, properties); } private redactValues( data: ICredentialDataDecryptedObject, props: INodeProperties[], - credentialType?: string, - ) { + ): ICredentialDataDecryptedObject { for (const dataKey of Object.keys(data)) { // The frontend only cares that this value isn't falsy. if (dataKey === 'oauthTokenData' || dataKey === 'csrfSecret') { @@ -869,15 +876,22 @@ export class CredentialsService { } else { data[dataKey] = CREDENTIAL_EMPTY_VALUE; } + continue; } - } - // Custom Auth: mask JSON after save (not marked as secret; redacted by type + field) - if (credentialType === 'httpCustomAuth' && 'json' in data) { - data.json = - data.json && String(data.json).length > 0 - ? CREDENTIAL_BLANKING_VALUE - : CREDENTIAL_EMPTY_VALUE; + if (prop.typeOptions?.redactJsonLeaves) { + const jsonStr = String(data[dataKey] ?? ''); + if (!jsonStr) { + data[dataKey] = CREDENTIAL_EMPTY_VALUE; + } else { + try { + const parsed = jsonParse(jsonStr); + data[dataKey] = JSON.stringify(this.redactJsonLeaves(parsed), null, 2); + } catch { + // Not parseable (e.g. an expression) — leave as-is + } + } + } } return data; @@ -888,21 +902,54 @@ export class CredentialsService { const values = data?.[collectionValuesKey]; if (Array.isArray(values)) { for (let i = 0; i < values.length; i++) { - values[i] = this.redactValues( - values[i] as ICredentialDataDecryptedObject, - option.values, - undefined, - ); + values[i] = this.redactValues(values[i] as ICredentialDataDecryptedObject, option.values); } } else if (typeof values === 'object' && values !== null) { data[collectionValuesKey] = this.redactValues( values as ICredentialDataDecryptedObject, option.values, - undefined, ); } } + private redactJsonLeaves(obj: unknown): unknown { + if (Array.isArray(obj)) return obj.map((item) => this.redactJsonLeaves(item)); + if (typeof obj === 'object' && obj !== null) { + return Object.fromEntries( + Object.entries(obj as Record).map(([k, v]) => [ + k, + this.redactJsonLeaves(v), + ]), + ); + } + return CUSTOM_AUTH_JSON_REDACTED_VALUE; + } + + private mergeRedactedJsonLeaves(newVal: unknown, savedVal: unknown): unknown { + if (newVal === CUSTOM_AUTH_JSON_REDACTED_VALUE) return savedVal; + if (Array.isArray(newVal) && Array.isArray(savedVal)) { + return newVal.map((item, i) => this.mergeRedactedJsonLeaves(item, savedVal[i])); + } + // Type mismatch involving arrays: user made a structural change — preserve new value as-is + if (Array.isArray(newVal) || Array.isArray(savedVal)) { + return newVal; + } + if ( + typeof newVal === 'object' && + newVal !== null && + typeof savedVal === 'object' && + savedVal !== null + ) { + return Object.fromEntries( + Object.entries(newVal as Record).map(([k, v]) => [ + k, + this.mergeRedactedJsonLeaves(v, (savedVal as Record)[k]), + ]), + ); + } + return newVal; + } + private unredactRestoreValues(unmerged: any, replacement: any) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument for (const [key, value] of Object.entries(unmerged)) { @@ -924,15 +971,38 @@ export class CredentialsService { } } - // Take unredacted data (probably from the DB) and merge it with - // redacted data to create an unredacted version. + getCredentialTypeProperties(credentialType: string): INodeProperties[] { + try { + return this.credentialTypes.getByName(credentialType).properties; + } catch { + return []; + } + } + + // Take redacted data (e.g. from the frontend) and merge it with saved data to produce a + // fully unredacted version ready for saving or testing. unredact( redactedData: ICredentialDataDecryptedObject, savedData: ICredentialDataDecryptedObject, + props: INodeProperties[] = [], ) { - // Replace any blank sentinel values with their saved version + // Replace field-level blank sentinels (CREDENTIAL_BLANKING_VALUE / CREDENTIAL_EMPTY_VALUE) const mergedData = deepCopy(redactedData); this.unredactRestoreValues(mergedData, savedData); + + // Merge leaf-level *** sentinels for fields marked with typeOptions.redactJsonLeaves + for (const prop of props) { + if (!prop.typeOptions?.redactJsonLeaves) continue; + if (!(prop.name in mergedData) || !(prop.name in savedData)) continue; + try { + const newJson: unknown = jsonParse(String(mergedData[prop.name])); + const savedJson: unknown = jsonParse(String(savedData[prop.name])); + mergedData[prop.name] = jsonStringify(this.mergeRedactedJsonLeaves(newJson, savedJson)); + } catch { + // Not parseable JSON — leave as-is + } + } + return mergedData; } diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue index 3c75afab07a..b2d1c37080e 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue @@ -13,7 +13,7 @@ import type { INodeProperties, ITelemetryTrackProperties, } from 'n8n-workflow'; -import { deepCopy, NodeHelpers } from 'n8n-workflow'; +import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow'; import CredentialIcon from '../CredentialIcon.vue'; import CredentialConfig from './CredentialConfig.vue'; @@ -301,6 +301,19 @@ const requiredPropertiesFilled = computed(() => { return false; } } + + if (property.type === 'json' && credentialProperty) { + const jsonValue = String(credentialProperty); + const containsExpression = isExpression(jsonValue); + // Sentinels represent a previously-saved value, and expressions are always valid + if (!containsExpression && jsonValue !== CREDENTIAL_EMPTY_VALUE) { + try { + JSON.parse(jsonValue); + } catch { + return false; + } + } + } } return true; }); @@ -856,6 +869,12 @@ async function saveCredential(): Promise { credentialId.value = credential.id; currentCredential.value = credential; + // Re-fetch to display server-redacted JSON shape for credentials with leaf-redacted fields + if (credentialProperties.value.some((p) => p.typeOptions?.redactJsonLeaves)) { + await loadCurrentCredential(credential.id); + setCredentialPropertyDefaults(); + } + if (isCredentialTestable.value) { isTesting.value = true; // Add the full data including defaults for testing diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue index 1d8ead9818f..89f3ac4ccd6 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue @@ -17,7 +17,6 @@ import type { NodeParameterValueType, } from 'n8n-workflow'; import { - CREDENTIAL_BLANKING_VALUE, CREDENTIAL_EMPTY_VALUE, IconOrEmojiSchema, isResourceLocatorValue, @@ -103,7 +102,6 @@ import { useBuilderStore } from '@/features/ai/assistant/builder.store'; import { ElColorPicker, ElDatePicker, ElDialog, ElSwitch } from 'element-plus'; import { - N8nButton, N8nIcon, N8nIconPicker, N8nInput, @@ -271,23 +269,41 @@ const isJsonPasswordField = computed(() => { return props.parameter.type === 'json' && isSecretParameter.value; }); +function hasRedactedLeaf(obj: unknown): boolean { + if (obj === '***') return true; + if (Array.isArray(obj)) return obj.some(hasRedactedLeaf); + if (typeof obj === 'object' && obj !== null) { + return Object.values(obj as Record).some(hasRedactedLeaf); + } + return false; +} + // Custom Auth: only credential with top-level "json" field; mask after save (backend redacts by type, not secret flag) const isCustomAuthJsonField = computed(() => { return props.eventSource === 'credentials' && props.parameter.name === 'json'; }); const isCredentialJsonValueRedacted = computed(() => { - return ( - props.modelValue === CREDENTIAL_BLANKING_VALUE || props.modelValue === CREDENTIAL_EMPTY_VALUE - ); -}); -const showCredentialJsonOverlay = computed(() => { - return isCustomAuthJsonField.value && isCredentialJsonValueRedacted.value; -}); -// In dialog, when Custom Auth json is redacted show blank (never show placeholder) -const credentialJsonEditorValue = computed(() => { - if (isCustomAuthJsonField.value && isCredentialJsonValueRedacted.value) { - return ''; + const val = props.modelValue; + if (val === CREDENTIAL_EMPTY_VALUE) return true; + // New: detect shaped-redacted JSON (backend replaces leaf values with ***) + if (isCustomAuthJsonField.value) { + try { + return hasRedactedLeaf(JSON.parse(String(val))); + } catch { + return false; + } } + return false; +}); +const isRedactedCustomAuthJson = computed( + () => isCustomAuthJsonField.value && isCredentialJsonValueRedacted.value, +); + +const credentialJsonEditorValue = computed(() => { + if (!isRedactedCustomAuthJson.value) return modelValueString.value; + // Empty json field — show empty editor so the user knows to fill it in + if (props.modelValue === CREDENTIAL_EMPTY_VALUE) return ''; + // Shaped-redacted JSON: backend already formatted with *** leaves — show as-is return modelValueString.value; }); @@ -1693,38 +1709,9 @@ onUpdated(async () => { -
- -
- - {{ i18n.baseText('parameterInput.edit') }} - -
-
- { border-bottom-left-radius: 0; } } - -.credentialJsonOverlay { - position: relative; -} - -.credentialJsonTextarea { - pointer-events: none; -} - -.credentialJsonTextarea :global(.input) { - color: var(--color--text--tint-2); - letter-spacing: 0.05em; -} - -.credentialJsonOverlayButton { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.credentialJsonOverlayButton > * { - pointer-events: auto; -} diff --git a/packages/nodes-base/credentials/HttpCustomAuth.credentials.ts b/packages/nodes-base/credentials/HttpCustomAuth.credentials.ts index 270ab626ebe..ac9b292f0a4 100644 --- a/packages/nodes-base/credentials/HttpCustomAuth.credentials.ts +++ b/packages/nodes-base/credentials/HttpCustomAuth.credentials.ts @@ -23,6 +23,9 @@ export class HttpCustomAuth implements ICredentialType { placeholder: '{ "headers": { "key" : "value" }, "body": { "key": "value" }, "qs": { "key": "value" } }', default: '', + typeOptions: { + redactJsonLeaves: true, + }, }, ]; } diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 97f117e15f8..889906aef21 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1623,6 +1623,7 @@ export interface INodePropertyTypeOptions { layout?: 'inline'; // Render sub-parameters side-by-side in a row }; password?: boolean; // Supported by: string + redactJsonLeaves?: boolean; // Supported by: json (credential fields only) — redacts leaf values instead of the whole field rows?: number; // Supported by: string showAlpha?: boolean; // Supported by: color sortable?: boolean; // Supported when "multipleValues" set to true