feat: masking support for SingleLineEditor (#2240)

* mask support for SingleLineEditor

* add secret visibility toggle button

* move visibility toggle into SingleLineComponent

Co-authored-by: Liz MacLean <18120837+lizziemac@users.noreply.github.com>

* fix eye button focus state

* center enabled and secret toggle

* fix input field scales to 100% width

---------

Co-authored-by: Liz MacLean <18120837+lizziemac@users.noreply.github.com>
This commit is contained in:
Max Bauer 2024-08-05 08:16:06 +02:00 committed by GitHub
parent 7c33fd413e
commit 741250068f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 123 additions and 13 deletions

View File

@ -5,7 +5,6 @@ import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor'; import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import { maskInputValue } from 'utils/collections';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex'; import { variableNameRegex } from 'utils/common/regex';
@ -96,10 +95,10 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<table> <table>
<thead> <thead>
<tr> <tr>
<td>Enabled</td> <td className="text-center">Enabled</td>
<td>Name</td> <td>Name</td>
<td>Value</td> <td>Value</td>
<td>Secret</td> <td className="text-center">Secret</td>
<td></td> <td></td>
</tr> </tr>
</thead> </thead>
@ -109,7 +108,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<td className="text-center"> <td className="text-center">
<input <input
type="checkbox" type="checkbox"
className="mr-3 mousetrap" className="mousetrap"
name={`${index}.enabled`} name={`${index}.enabled`}
checked={variable.enabled} checked={variable.enabled}
onChange={formik.handleChange} onChange={formik.handleChange}
@ -130,23 +129,22 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
/> />
<ErrorMessage name={`${index}.name`} /> <ErrorMessage name={`${index}.name`} />
</td> </td>
<td> <td className="flex flex-row flex-nowrap">
{variable.secret ? ( <div className="overflow-hidden grow w-full relative">
<div className="overflow-hidden text-ellipsis">{maskInputValue(variable.value)}</div>
) : (
<SingleLineEditor <SingleLineEditor
theme={storedTheme} theme={storedTheme}
collection={collection} collection={collection}
name={`${index}.value`} name={`${index}.value`}
value={variable.value} value={variable.value}
isSecret={variable.secret}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)} onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/> />
)} </div>
</td> </td>
<td> <td className="text-center">
<input <input
type="checkbox" type="checkbox"
className="mr-3 mousetrap" className="mousetrap"
name={`${index}.secret`} name={`${index}.secret`}
checked={variable.secret} checked={variable.secret}
onChange={formik.handleChange} onChange={formik.handleChange}

View File

@ -1,8 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections'; import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
let CodeMirror; let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
@ -20,6 +21,10 @@ class SingleLineEditor extends Component {
this.cachedValue = props.value || ''; this.cachedValue = props.value || '';
this.editorRef = React.createRef(); this.editorRef = React.createRef();
this.variables = {}; this.variables = {};
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
} }
componentDidMount() { componentDidMount() {
// Initialize CodeMirror as a single line editor // Initialize CodeMirror as a single line editor
@ -81,8 +86,24 @@ class SingleLineEditor extends Component {
this.editor.setValue(String(this.props.value) || ''); this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit); this.editor.on('change', this._onEdit);
this.addOverlay(variables); this.addOverlay(variables);
this._enableMaskedEditor(this.props.isSecret);
this.setState({ maskInput: this.props.isSecret });
} }
/** Enable or disable masking the rendered content of the editor */
_enableMaskedEditor = (enabled) => {
if (typeof enabled !== 'boolean') return;
console.log('Enabling masked editor: ' + enabled);
if (enabled == true) {
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
this.maskedEditor.enable();
} else {
this.maskedEditor?.disable();
this.maskedEditor = null;
}
};
_onEdit = () => { _onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) { if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue(); this.cachedValue = this.editor.getValue();
@ -110,6 +131,12 @@ class SingleLineEditor extends Component {
this.cachedValue = String(this.props.value); this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || ''); this.editor.setValue(String(this.props.value) || '');
} }
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
this._enableMaskedEditor(this.props.isSecret);
// also set the maskInput flag to the new value
this.setState({ maskInput: this.props.isSecret });
}
this.ignoreChangeEvent = false; this.ignoreChangeEvent = false;
} }
@ -123,8 +150,35 @@ class SingleLineEditor extends Component {
this.editor.setOption('mode', 'brunovariables'); this.editor.setOption('mode', 'brunovariables');
}; };
toggleVisibleSecret = () => {
const isVisible = !this.state.maskInput;
this.setState({ maskInput: isVisible });
this._enableMaskedEditor(isVisible);
};
/**
* @brief Eye icon to show/hide the secret value
* @returns ReactComponent The eye icon
*/
secretEye = (isSecret) => {
return isSecret === true ? (
<button className="mx-2" onClick={() => this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
<IconEyeOff size={18} strokeWidth={2} />
) : (
<IconEye size={18} strokeWidth={2} />
)}
</button>
) : null;
};
render() { render() {
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>; return (
<div className="flex flex-row justify-between w-full">
<StyledWrapper ref={this.editorRef} className="single-line-editor grow" />
{this.secretEye(this.props.isSecret)}
</div>
);
} }
} }
export default SingleLineEditor; export default SingleLineEditor;

View File

@ -12,6 +12,64 @@ const pathFoundInVariables = (path, obj) => {
return value !== undefined; return value !== undefined;
}; };
/**
* Changes the render behaviour for a given CodeMirror editor.
* Replaces all **rendered** characters, not the actual value, with the provided character.
*/
export class MaskedEditor {
/**
* @param {import('codemirror').Editor} editor CodeMirror editor instance
* @param {string} maskChar Target character being applied to all content
*/
constructor(editor, maskChar) {
this.editor = editor;
this.maskChar = maskChar;
this.enabled = false;
}
/**
* Set and apply new masking character
*/
enable = () => {
this.enabled = true;
this.editor.setValue(this.editor.getValue());
this.editor.on('inputRead', this.maskContent);
this.update();
};
/** Disables masking of the editor field. */
disable = () => {
this.enabled = false;
this.editor.off('inputRead', this.maskContent);
this.editor.setValue(this.editor.getValue());
};
/** Updates the rendered content if enabled. */
update = () => {
if (this.enabled) this.maskContent();
};
/** Replaces all rendered characters, with the provided character. */
maskContent = () => {
const content = this.editor.getValue();
this.editor.operation(() => {
// Clear previous masked text
this.editor.getAllMarks().forEach((mark) => mark.clear());
// Apply new masked text
for (let i = 0; i < content.length; i++) {
if (content[i] !== '\n') {
const maskedNode = document.createTextNode(this.maskChar);
this.editor.markText(
{ line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
{ line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
{ replacedWith: maskedNode, handleMouseEvents: true }
);
}
}
});
};
}
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => { export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) { CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
const { pathParams = {}, ...variables } = _variables || {}; const { pathParams = {}, ...variables } = _variables || {};