Improve sensitive config variable masking and editing UX (#19578)

## Summary
This PR enhances the handling of sensitive configuration variables by
improving masking logic and user experience when editing them. It adds
metadata-aware masking for dynamically marked sensitive variables and
clears sensitive values when entering edit mode.

## Key Changes

- **Backend masking improvements**: Updated `maskSensitiveValue()` to
accept metadata parameter, enabling masking of variables marked as
sensitive via metadata (not just predefined masking config). Sensitive
non-string values are masked as `********`.

- **Edit mode UX**: When editing a sensitive variable, the value field
is now cleared on entering edit mode to prevent exposing masked values
and ensure users intentionally provide new secret values.

- **Form state tracking**: Enhanced `useConfigVariableForm()` hook to
accept an `isEditing` parameter and properly track value changes for
sensitive variables during edit operations.

- **Input placeholder**: Updated placeholder text for sensitive variable
inputs to show `Enter a new secret value` instead of the generic
database storage message, providing clearer intent to users.

## Implementation Details

- The `maskSensitiveValue()` method now checks both predefined masking
configurations and runtime metadata to determine if a value should be
masked
- Sensitive string values use LAST_N_CHARS strategy (4 characters),
while non-string values are masked uniformly
- The form's `hasValueChanged` flag is set to true when editing
sensitive variables to ensure proper validation and submission handling

https://claude.ai/code/session_01JiTckmuVJMQWpJ7TUGwsqb

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-11 22:07:05 +02:00
committed by GitHub
parent b37ef3e7da
commit 4c57352450
4 changed files with 85 additions and 52 deletions
@@ -121,7 +121,7 @@ export class TwentyConfigService {
let value = this.get(typedKey) ?? '';
const source = this.determineConfigSource(typedKey, value, envMetadata);
value = this.maskSensitiveValue(typedKey, value);
value = this.maskSensitiveValue(typedKey, value, envMetadata);
result[key] = {
value,
@@ -147,7 +147,7 @@ export class TwentyConfigService {
let value = this.get(key) ?? '';
const source = this.determineConfigSource(key, value, metadata);
value = this.maskSensitiveValue(key, value);
value = this.maskSensitiveValue(key, value, metadata);
return {
value,
@@ -248,26 +248,47 @@ export class TwentyConfigService {
key: T,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
value: any,
metadata: ConfigVariablesMetadataOptions,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
): any {
if (!isString(value) || !(key in CONFIG_VARIABLES_MASKING_CONFIG)) {
return value;
if (key in CONFIG_VARIABLES_MASKING_CONFIG) {
if (!isString(value)) {
return value;
}
const varMaskingConfig =
CONFIG_VARIABLES_MASKING_CONFIG[
key as keyof typeof CONFIG_VARIABLES_MASKING_CONFIG
];
const options =
varMaskingConfig.strategy ===
ConfigVariablesMaskingStrategies.LAST_N_CHARS
? { chars: varMaskingConfig.chars }
: undefined;
return configVariableMaskSensitiveData(value, varMaskingConfig.strategy, {
...options,
variableName: key as string,
});
}
const varMaskingConfig =
CONFIG_VARIABLES_MASKING_CONFIG[
key as keyof typeof CONFIG_VARIABLES_MASKING_CONFIG
];
const options =
varMaskingConfig.strategy ===
ConfigVariablesMaskingStrategies.LAST_N_CHARS
? { chars: varMaskingConfig.chars }
: undefined;
if (metadata?.isSensitive) {
if (!value && value !== false && value !== 0) {
return value;
}
return configVariableMaskSensitiveData(value, varMaskingConfig.strategy, {
...options,
variableName: key as string,
});
if (isString(value)) {
return configVariableMaskSensitiveData(
value,
ConfigVariablesMaskingStrategies.LAST_N_CHARS,
{ chars: 4, variableName: key as string },
);
}
return '********';
}
return value;
}
validateConfigVariableExists(key: string): boolean {