feat(core): Improve custom auth JSON masking, preserve shape, redact leaf values (#27675)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart
2026-04-01 12:24:21 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 72b41a9a76
commit d36cfa1624
7 changed files with 496 additions and 103 deletions
@@ -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<CredentialsEntity>({ 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<CredentialsEntity>({
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';
@@ -169,6 +169,7 @@ export class CredentialsController {
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
this.credentialsService.getCredentialTypeProperties(storedCredential.type),
);
}
@@ -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<unknown>(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<string, unknown>).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<string, unknown>).map(([k, v]) => [
k,
this.mergeRedactedJsonLeaves(v, (savedVal as Record<string, unknown>)[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;
}
@@ -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<ICredentialsResponse | null> {
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
@@ -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<boolean>(() => {
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<string, unknown>).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<boolean>(() => {
return props.eventSource === 'credentials' && props.parameter.name === 'json';
});
const isCredentialJsonValueRedacted = computed<boolean>(() => {
return (
props.modelValue === CREDENTIAL_BLANKING_VALUE || props.modelValue === CREDENTIAL_EMPTY_VALUE
);
});
const showCredentialJsonOverlay = computed<boolean>(() => {
return isCustomAuthJsonField.value && isCredentialJsonValueRedacted.value;
});
// In dialog, when Custom Auth json is redacted show blank (never show placeholder)
const credentialJsonEditorValue = computed<string>(() => {
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<boolean>(
() => isCustomAuthJsonField.value && isCredentialJsonValueRedacted.value,
);
const credentialJsonEditorValue = computed<string>(() => {
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 () => {
</template>
</JsEditor>
<div
v-else-if="showCredentialJsonOverlay"
:class="$style.credentialJsonOverlay"
data-test-id="credential-json-overlay"
>
<JsonEditor
:model-value="'***\n***\n***'"
:is-read-only="true"
:rows="editorRows"
:class="$style.credentialJsonEditor"
/>
<div :class="$style.credentialJsonOverlayButton">
<N8nButton
size="small"
secondary
icon="pencil"
data-test-id="credential-json-edit-button"
@click="valueChanged('')"
>
{{ i18n.baseText('parameterInput.edit') }}
</N8nButton>
</div>
</div>
<JsonEditor
v-else-if="
parameter.type === 'json' &&
!codeEditDialogVisible &&
!showCredentialJsonOverlay &&
!isSecretParameter
"
:model-value="modelValueString"
v-else-if="parameter.type === 'json' && !codeEditDialogVisible && !isSecretParameter"
:model-value="credentialJsonEditorValue"
:is-read-only="isReadOnly"
:rows="editorRows"
@update:model-value="valueChangedDebounced"
@@ -2288,30 +2275,4 @@ onUpdated(async () => {
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;
}
</style>
@@ -23,6 +23,9 @@ export class HttpCustomAuth implements ICredentialType {
placeholder:
'{ "headers": { "key" : "value" }, "body": { "key": "value" }, "qs": { "key": "value" } }',
default: '',
typeOptions: {
redactJsonLeaves: true,
},
},
];
}
+1
View File
@@ -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