secure-ui-components 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/components/secure-datetime/secure-datetime.css +263 -0
- package/dist/components/secure-datetime/secure-datetime.d.ts +124 -0
- package/dist/components/secure-datetime/secure-datetime.d.ts.map +1 -0
- package/dist/components/secure-datetime/secure-datetime.js +610 -0
- package/dist/components/secure-datetime/secure-datetime.js.map +1 -0
- package/dist/components/secure-file-upload/secure-file-upload.css +334 -0
- package/dist/components/secure-file-upload/secure-file-upload.d.ts +150 -0
- package/dist/components/secure-file-upload/secure-file-upload.d.ts.map +1 -0
- package/dist/components/secure-file-upload/secure-file-upload.js +911 -0
- package/dist/components/secure-file-upload/secure-file-upload.js.map +1 -0
- package/dist/components/secure-form/secure-form.css +62 -0
- package/dist/components/secure-form/secure-form.d.ts +128 -0
- package/dist/components/secure-form/secure-form.d.ts.map +1 -0
- package/dist/components/secure-form/secure-form.js +697 -0
- package/dist/components/secure-form/secure-form.js.map +1 -0
- package/dist/components/secure-input/secure-input.css +168 -0
- package/dist/components/secure-input/secure-input.d.ts +114 -0
- package/dist/components/secure-input/secure-input.d.ts.map +1 -0
- package/dist/components/secure-input/secure-input.js +785 -0
- package/dist/components/secure-input/secure-input.js.map +1 -0
- package/dist/components/secure-select/secure-select.css +195 -0
- package/dist/components/secure-select/secure-select.d.ts +149 -0
- package/dist/components/secure-select/secure-select.d.ts.map +1 -0
- package/dist/components/secure-select/secure-select.js +634 -0
- package/dist/components/secure-select/secure-select.js.map +1 -0
- package/dist/components/secure-submit-button/secure-submit-button.css +135 -0
- package/dist/components/secure-submit-button/secure-submit-button.d.ts +61 -0
- package/dist/components/secure-submit-button/secure-submit-button.d.ts.map +1 -0
- package/dist/components/secure-submit-button/secure-submit-button.js +399 -0
- package/dist/components/secure-submit-button/secure-submit-button.js.map +1 -0
- package/dist/components/secure-table/secure-table.css +341 -0
- package/dist/components/secure-table/secure-table.d.ts +64 -0
- package/dist/components/secure-table/secure-table.d.ts.map +1 -0
- package/dist/components/secure-table/secure-table.js +567 -0
- package/dist/components/secure-table/secure-table.js.map +1 -0
- package/dist/components/secure-textarea/secure-textarea.css +153 -0
- package/dist/components/secure-textarea/secure-textarea.d.ts +111 -0
- package/dist/components/secure-textarea/secure-textarea.d.ts.map +1 -0
- package/dist/components/secure-textarea/secure-textarea.js +477 -0
- package/dist/components/secure-textarea/secure-textarea.js.map +1 -0
- package/dist/core/base-component.d.ts +134 -0
- package/dist/core/base-component.d.ts.map +1 -0
- package/dist/core/base-component.js +303 -0
- package/dist/core/base-component.js.map +1 -0
- package/dist/core/base.css +37 -0
- package/dist/core/security-config.d.ts +89 -0
- package/dist/core/security-config.d.ts.map +1 -0
- package/dist/core/security-config.js +273 -0
- package/dist/core/security-config.js.map +1 -0
- package/dist/core/types.d.ts +212 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +7 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +89 -0
- package/dist/styles/tokens.css +257 -0
- package/package.json +118 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Secure Input Component
|
|
3
|
+
*
|
|
4
|
+
* A security-first input field component that implements progressive enhancement,
|
|
5
|
+
* tier-based validation, masking, and audit logging.
|
|
6
|
+
*
|
|
7
|
+
* Progressive Enhancement Strategy:
|
|
8
|
+
* 1. Without JavaScript: Falls back to native HTML5 input with attributes
|
|
9
|
+
* 2. With JavaScript: Enhances with masking, real-time validation, rate limiting
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* <secure-input
|
|
13
|
+
* security-tier="critical"
|
|
14
|
+
* name="password"
|
|
15
|
+
* label="Password"
|
|
16
|
+
* type="password"
|
|
17
|
+
* required
|
|
18
|
+
* ></secure-input>
|
|
19
|
+
*
|
|
20
|
+
* Security Features:
|
|
21
|
+
* - XSS prevention via sanitization
|
|
22
|
+
* - Configurable masking based on security tier
|
|
23
|
+
* - Rate limiting for sensitive/critical tiers
|
|
24
|
+
* - Autocomplete control based on tier
|
|
25
|
+
* - Comprehensive audit logging
|
|
26
|
+
* - Visual security indicators
|
|
27
|
+
*
|
|
28
|
+
* @module secure-input
|
|
29
|
+
* @license MIT
|
|
30
|
+
*/
|
|
31
|
+
import { SecureBaseComponent } from '../../core/base-component.js';
|
|
32
|
+
import { SecurityTier } from '../../core/security-config.js';
|
|
33
|
+
/**
|
|
34
|
+
* Secure Input Web Component
|
|
35
|
+
*
|
|
36
|
+
* Provides a security-hardened input field with progressive enhancement.
|
|
37
|
+
* The component works as a standard form input without JavaScript and
|
|
38
|
+
* enhances with security features when JavaScript is available.
|
|
39
|
+
*
|
|
40
|
+
* @extends SecureBaseComponent
|
|
41
|
+
*/
|
|
42
|
+
export class SecureInput extends SecureBaseComponent {
|
|
43
|
+
/**
|
|
44
|
+
* Input element reference
|
|
45
|
+
* @private
|
|
46
|
+
*/
|
|
47
|
+
#inputElement = null;
|
|
48
|
+
/**
|
|
49
|
+
* Label element reference
|
|
50
|
+
* @private
|
|
51
|
+
*/
|
|
52
|
+
#labelElement = null;
|
|
53
|
+
/**
|
|
54
|
+
* Error container element reference
|
|
55
|
+
* @private
|
|
56
|
+
*/
|
|
57
|
+
#errorContainer = null;
|
|
58
|
+
/**
|
|
59
|
+
* The actual unmasked value
|
|
60
|
+
* @private
|
|
61
|
+
*/
|
|
62
|
+
#actualValue = '';
|
|
63
|
+
/**
|
|
64
|
+
* Whether the input is currently masked
|
|
65
|
+
* @private
|
|
66
|
+
*/
|
|
67
|
+
#isMasked = false;
|
|
68
|
+
/**
|
|
69
|
+
* Hidden input element in light DOM for form submission
|
|
70
|
+
* @private
|
|
71
|
+
*/
|
|
72
|
+
#hiddenInput = null;
|
|
73
|
+
/**
|
|
74
|
+
* Unique ID for this input instance
|
|
75
|
+
* @private
|
|
76
|
+
*/
|
|
77
|
+
#instanceId = `secure-input-${Math.random().toString(36).substr(2, 9)}`;
|
|
78
|
+
/**
|
|
79
|
+
* Observed attributes for this component
|
|
80
|
+
*
|
|
81
|
+
* @static
|
|
82
|
+
*/
|
|
83
|
+
static get observedAttributes() {
|
|
84
|
+
return [
|
|
85
|
+
...super.observedAttributes,
|
|
86
|
+
'name',
|
|
87
|
+
'type',
|
|
88
|
+
'label',
|
|
89
|
+
'placeholder',
|
|
90
|
+
'required',
|
|
91
|
+
'pattern',
|
|
92
|
+
'minlength',
|
|
93
|
+
'maxlength',
|
|
94
|
+
'autocomplete',
|
|
95
|
+
'value'
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Constructor
|
|
100
|
+
*/
|
|
101
|
+
constructor() {
|
|
102
|
+
super();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Render the input component
|
|
106
|
+
*
|
|
107
|
+
* Security Note: We use a native <input> element wrapped in our web component
|
|
108
|
+
* to ensure progressive enhancement. The native input works without JavaScript,
|
|
109
|
+
* and we enhance it with security features when JS is available.
|
|
110
|
+
*
|
|
111
|
+
* @protected
|
|
112
|
+
*/
|
|
113
|
+
render() {
|
|
114
|
+
const fragment = document.createDocumentFragment();
|
|
115
|
+
const config = this.config;
|
|
116
|
+
// Create container
|
|
117
|
+
const container = document.createElement('div');
|
|
118
|
+
container.className = 'input-container';
|
|
119
|
+
container.setAttribute('part', 'container');
|
|
120
|
+
// Create label
|
|
121
|
+
const label = this.getAttribute('label');
|
|
122
|
+
if (label) {
|
|
123
|
+
this.#labelElement = document.createElement('label');
|
|
124
|
+
this.#labelElement.htmlFor = this.#instanceId;
|
|
125
|
+
this.#labelElement.textContent = this.sanitizeValue(label);
|
|
126
|
+
this.#labelElement.setAttribute('part', 'label');
|
|
127
|
+
// Add security tier suffix if configured
|
|
128
|
+
if (config.ui.labelSuffix) {
|
|
129
|
+
const suffix = document.createElement('span');
|
|
130
|
+
suffix.className = 'label-suffix';
|
|
131
|
+
suffix.setAttribute('part', 'label-suffix');
|
|
132
|
+
suffix.textContent = config.ui.labelSuffix;
|
|
133
|
+
this.#labelElement.appendChild(suffix);
|
|
134
|
+
}
|
|
135
|
+
// Add security badge if configured
|
|
136
|
+
if (config.ui.showSecurityBadge) {
|
|
137
|
+
const badge = document.createElement('span');
|
|
138
|
+
badge.className = 'security-badge';
|
|
139
|
+
badge.setAttribute('part', 'security-badge');
|
|
140
|
+
badge.textContent = config.name;
|
|
141
|
+
this.#labelElement.appendChild(badge);
|
|
142
|
+
}
|
|
143
|
+
container.appendChild(this.#labelElement);
|
|
144
|
+
}
|
|
145
|
+
// Create input wrapper for progressive enhancement
|
|
146
|
+
const inputWrapper = document.createElement('div');
|
|
147
|
+
inputWrapper.className = 'input-wrapper';
|
|
148
|
+
inputWrapper.setAttribute('part', 'wrapper');
|
|
149
|
+
// Create the actual input element
|
|
150
|
+
this.#inputElement = document.createElement('input');
|
|
151
|
+
this.#inputElement.id = this.#instanceId;
|
|
152
|
+
this.#inputElement.className = 'input-field';
|
|
153
|
+
this.#inputElement.setAttribute('part', 'input');
|
|
154
|
+
// Apply attributes from web component to native input
|
|
155
|
+
this.#applyInputAttributes();
|
|
156
|
+
// Set up event listeners
|
|
157
|
+
this.#attachEventListeners();
|
|
158
|
+
inputWrapper.appendChild(this.#inputElement);
|
|
159
|
+
container.appendChild(inputWrapper);
|
|
160
|
+
// Create error container
|
|
161
|
+
// role="alert" already implies aria-live="assertive" — do not override with polite
|
|
162
|
+
this.#errorContainer = document.createElement('div');
|
|
163
|
+
this.#errorContainer.className = 'error-container hidden';
|
|
164
|
+
this.#errorContainer.setAttribute('role', 'alert');
|
|
165
|
+
this.#errorContainer.setAttribute('part', 'error');
|
|
166
|
+
this.#errorContainer.id = `${this.#instanceId}-error`;
|
|
167
|
+
container.appendChild(this.#errorContainer);
|
|
168
|
+
// CRITICAL: Create hidden input in light DOM for native form submission
|
|
169
|
+
// The actual input is in Shadow DOM and can't participate in form submission
|
|
170
|
+
this.#createHiddenInputForForm();
|
|
171
|
+
// CRITICAL: Neutralize native fallback inputs in light DOM.
|
|
172
|
+
// The server renders native <input> elements inside <secure-input> for no-JS
|
|
173
|
+
// progressive enhancement. Now that JS has loaded and the shadow DOM input is
|
|
174
|
+
// active, these native fallbacks must be neutralized:
|
|
175
|
+
// 1. They are hidden by shadow DOM so users can't interact with them
|
|
176
|
+
// 2. They still have 'required' attributes that trigger HTML5 constraint
|
|
177
|
+
// validation, silently blocking form submission (browser can't show the
|
|
178
|
+
// validation popup for a hidden element, so "nothing happens" on click)
|
|
179
|
+
// 3. They still have 'name' attributes causing duplicate empty form fields
|
|
180
|
+
this.#neutralizeFallbackInputs();
|
|
181
|
+
// Add component styles (CSP-compliant via adoptedStyleSheets)
|
|
182
|
+
this.addComponentStyles(this.#getComponentStyles());
|
|
183
|
+
fragment.appendChild(container);
|
|
184
|
+
return fragment;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Create hidden input in light DOM for native form submission
|
|
188
|
+
*
|
|
189
|
+
* CRITICAL: The actual input is in Shadow DOM and can't participate in
|
|
190
|
+
* native form submission. We create a hidden input in light DOM that syncs
|
|
191
|
+
* with the Shadow DOM input value.
|
|
192
|
+
*
|
|
193
|
+
* IMPORTANT: Only create hidden input if NOT inside a <secure-form> component.
|
|
194
|
+
* The secure-form component handles its own hidden input creation.
|
|
195
|
+
*
|
|
196
|
+
* @private
|
|
197
|
+
*/
|
|
198
|
+
#createHiddenInputForForm() {
|
|
199
|
+
const name = this.getAttribute('name');
|
|
200
|
+
if (!name)
|
|
201
|
+
return;
|
|
202
|
+
// Check if this input is inside a <secure-form> component
|
|
203
|
+
// If yes, the secure-form will handle hidden input creation
|
|
204
|
+
const isInsideSecureForm = this.closest('secure-form');
|
|
205
|
+
if (isInsideSecureForm) {
|
|
206
|
+
// Don't create hidden input - secure-form will handle it
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Create hidden input in light DOM
|
|
210
|
+
this.#hiddenInput = document.createElement('input');
|
|
211
|
+
this.#hiddenInput.type = 'hidden';
|
|
212
|
+
this.#hiddenInput.name = name;
|
|
213
|
+
this.#hiddenInput.value = this.#actualValue || '';
|
|
214
|
+
// Append to light DOM (this element, not shadow root)
|
|
215
|
+
this.appendChild(this.#hiddenInput);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Sync hidden input value with the actual input value
|
|
219
|
+
*
|
|
220
|
+
* @private
|
|
221
|
+
*/
|
|
222
|
+
#syncHiddenInput() {
|
|
223
|
+
if (this.#hiddenInput) {
|
|
224
|
+
this.#hiddenInput.value = this.#actualValue || '';
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Neutralize native fallback inputs in light DOM
|
|
229
|
+
*
|
|
230
|
+
* When the component initializes with JavaScript, the shadow DOM input takes
|
|
231
|
+
* over. The server-rendered native fallback inputs (for no-JS progressive
|
|
232
|
+
* enhancement) must be neutralized to prevent:
|
|
233
|
+
* - HTML5 constraint validation blocking form submission silently
|
|
234
|
+
* - Duplicate form field values on native form submission
|
|
235
|
+
*
|
|
236
|
+
* @private
|
|
237
|
+
*/
|
|
238
|
+
#neutralizeFallbackInputs() {
|
|
239
|
+
const fallbacks = this.querySelectorAll('input, textarea, select');
|
|
240
|
+
fallbacks.forEach((el) => {
|
|
241
|
+
// Skip the hidden input we created for form submission
|
|
242
|
+
if (el === this.#hiddenInput)
|
|
243
|
+
return;
|
|
244
|
+
const input = el;
|
|
245
|
+
// Remove attributes that interfere with form submission
|
|
246
|
+
input.removeAttribute('required');
|
|
247
|
+
input.removeAttribute('name');
|
|
248
|
+
input.removeAttribute('minlength');
|
|
249
|
+
input.removeAttribute('maxlength');
|
|
250
|
+
input.removeAttribute('pattern');
|
|
251
|
+
// Mark as inert so it's completely non-interactive
|
|
252
|
+
input.setAttribute('tabindex', '-1');
|
|
253
|
+
input.setAttribute('aria-hidden', 'true');
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Apply attributes from the web component to the native input
|
|
258
|
+
*
|
|
259
|
+
* Security Note: This is where we enforce tier-specific security controls
|
|
260
|
+
* like autocomplete, caching, and validation rules.
|
|
261
|
+
*
|
|
262
|
+
* @private
|
|
263
|
+
*/
|
|
264
|
+
#applyInputAttributes() {
|
|
265
|
+
const config = this.config;
|
|
266
|
+
// Name attribute (required for form submission)
|
|
267
|
+
const name = this.getAttribute('name');
|
|
268
|
+
if (name) {
|
|
269
|
+
this.#inputElement.name = this.sanitizeValue(name);
|
|
270
|
+
}
|
|
271
|
+
// Accessible name fallback: when no visible label is provided, use the name
|
|
272
|
+
// attribute as aria-label so screen readers can identify the field
|
|
273
|
+
if (!this.getAttribute('label') && name) {
|
|
274
|
+
this.#inputElement.setAttribute('aria-label', this.sanitizeValue(name));
|
|
275
|
+
}
|
|
276
|
+
// Link input to its error container for screen readers
|
|
277
|
+
this.#inputElement.setAttribute('aria-describedby', `${this.#instanceId}-error`);
|
|
278
|
+
// Type attribute
|
|
279
|
+
const type = this.getAttribute('type') || 'text';
|
|
280
|
+
this.#inputElement.type = type;
|
|
281
|
+
// Placeholder
|
|
282
|
+
const placeholder = this.getAttribute('placeholder');
|
|
283
|
+
if (placeholder) {
|
|
284
|
+
this.#inputElement.placeholder = this.sanitizeValue(placeholder);
|
|
285
|
+
}
|
|
286
|
+
// Required attribute
|
|
287
|
+
if (this.hasAttribute('required') || config.validation.required) {
|
|
288
|
+
this.#inputElement.required = true;
|
|
289
|
+
this.#inputElement.setAttribute('aria-required', 'true');
|
|
290
|
+
}
|
|
291
|
+
// Pattern validation
|
|
292
|
+
const pattern = this.getAttribute('pattern');
|
|
293
|
+
if (pattern) {
|
|
294
|
+
this.#inputElement.pattern = pattern;
|
|
295
|
+
}
|
|
296
|
+
// Length constraints
|
|
297
|
+
const minLength = this.getAttribute('minlength');
|
|
298
|
+
if (minLength) {
|
|
299
|
+
this.#inputElement.minLength = parseInt(minLength, 10);
|
|
300
|
+
}
|
|
301
|
+
const maxLength = this.getAttribute('maxlength') || config.validation.maxLength;
|
|
302
|
+
if (maxLength) {
|
|
303
|
+
this.#inputElement.maxLength = parseInt(String(maxLength), 10);
|
|
304
|
+
}
|
|
305
|
+
// CRITICAL SECURITY: Autocomplete control based on tier
|
|
306
|
+
// For SENSITIVE and CRITICAL tiers, we disable autocomplete to prevent
|
|
307
|
+
// browser storage of sensitive data
|
|
308
|
+
if (config.storage.allowAutocomplete) {
|
|
309
|
+
const autocomplete = this.getAttribute('autocomplete') || 'on';
|
|
310
|
+
this.#inputElement.autocomplete = autocomplete;
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
// Explicitly disable autocomplete for sensitive data
|
|
314
|
+
this.#inputElement.autocomplete = 'off';
|
|
315
|
+
// Also set autocomplete="new-password" for password fields to prevent
|
|
316
|
+
// password managers from auto-filling
|
|
317
|
+
if (this.#inputElement.type === 'password') {
|
|
318
|
+
this.#inputElement.autocomplete = 'new-password';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Disabled state
|
|
322
|
+
if (this.hasAttribute('disabled')) {
|
|
323
|
+
this.#inputElement.disabled = true;
|
|
324
|
+
}
|
|
325
|
+
// Readonly state
|
|
326
|
+
if (this.hasAttribute('readonly')) {
|
|
327
|
+
this.#inputElement.readOnly = true;
|
|
328
|
+
}
|
|
329
|
+
// Initial value
|
|
330
|
+
const value = this.getAttribute('value');
|
|
331
|
+
if (value) {
|
|
332
|
+
this.#setValue(value);
|
|
333
|
+
}
|
|
334
|
+
// Apply masking if configured for this tier
|
|
335
|
+
if (config.masking.enabled && this.#inputElement.type !== 'password') {
|
|
336
|
+
this.#isMasked = true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Attach event listeners to the input
|
|
341
|
+
*
|
|
342
|
+
* @private
|
|
343
|
+
*/
|
|
344
|
+
#attachEventListeners() {
|
|
345
|
+
// Focus event - audit logging
|
|
346
|
+
this.#inputElement.addEventListener('focus', () => {
|
|
347
|
+
this.audit('input_focused', {
|
|
348
|
+
name: this.#inputElement.name
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
// Input event - real-time validation and change tracking
|
|
352
|
+
this.#inputElement.addEventListener('input', (e) => {
|
|
353
|
+
this.#handleInput(e);
|
|
354
|
+
});
|
|
355
|
+
// Blur event - final validation
|
|
356
|
+
this.#inputElement.addEventListener('blur', () => {
|
|
357
|
+
this.#validateAndShowErrors();
|
|
358
|
+
this.audit('input_blurred', {
|
|
359
|
+
name: this.#inputElement.name,
|
|
360
|
+
hasValue: this.#actualValue.length > 0
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
// Change event - audit logging
|
|
364
|
+
this.#inputElement.addEventListener('change', () => {
|
|
365
|
+
this.audit('input_changed', {
|
|
366
|
+
name: this.#inputElement.name,
|
|
367
|
+
valueLength: this.#actualValue.length
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Handle input events
|
|
373
|
+
*
|
|
374
|
+
* Security Note: This is where we implement real-time masking and validation.
|
|
375
|
+
* We never expose the actual value in the DOM for CRITICAL tier fields.
|
|
376
|
+
*
|
|
377
|
+
* @private
|
|
378
|
+
*/
|
|
379
|
+
#handleInput(event) {
|
|
380
|
+
// For masked inputs (except password which browser handles), we need to track
|
|
381
|
+
// the actual unmasked value separately because the input element shows masked chars
|
|
382
|
+
if (this.#isMasked && this.#inputElement.type !== 'password') {
|
|
383
|
+
const inputEvent = event;
|
|
384
|
+
const inputType = inputEvent.inputType;
|
|
385
|
+
const data = inputEvent.data || '';
|
|
386
|
+
// Get current state before we modify
|
|
387
|
+
const currentDisplayValue = this.#inputElement.value;
|
|
388
|
+
const cursorPos = this.#inputElement.selectionStart || 0;
|
|
389
|
+
// Handle different input types by reconstructing the actual value
|
|
390
|
+
if (inputType === 'deleteContentBackward') {
|
|
391
|
+
// Backspace: remove character before cursor
|
|
392
|
+
if (cursorPos < this.#actualValue.length) {
|
|
393
|
+
this.#actualValue = this.#actualValue.substring(0, cursorPos) +
|
|
394
|
+
this.#actualValue.substring(cursorPos + 1);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
else if (inputType === 'deleteContentForward') {
|
|
398
|
+
// Delete key: character at cursor already removed, cursor position is correct
|
|
399
|
+
this.#actualValue = this.#actualValue.substring(0, cursorPos) +
|
|
400
|
+
this.#actualValue.substring(cursorPos + 1);
|
|
401
|
+
}
|
|
402
|
+
else if (inputType === 'insertText') {
|
|
403
|
+
// User typed a character - insert at cursor position
|
|
404
|
+
this.#actualValue = this.#actualValue.substring(0, cursorPos - data.length) +
|
|
405
|
+
data +
|
|
406
|
+
this.#actualValue.substring(cursorPos - data.length);
|
|
407
|
+
}
|
|
408
|
+
else if (inputType === 'insertFromPaste') {
|
|
409
|
+
// User pasted - the data might be the full pasted content
|
|
410
|
+
if (data) {
|
|
411
|
+
this.#actualValue = this.#actualValue.substring(0, cursorPos - data.length) +
|
|
412
|
+
data +
|
|
413
|
+
this.#actualValue.substring(cursorPos - data.length);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
// For any other input type, use a simpler approach:
|
|
418
|
+
// The display shows masked chars, but we can infer changes by comparing lengths
|
|
419
|
+
const oldLength = this.#actualValue.length;
|
|
420
|
+
const newLength = currentDisplayValue.length;
|
|
421
|
+
if (newLength > oldLength) {
|
|
422
|
+
// Something was added
|
|
423
|
+
const diff = newLength - oldLength;
|
|
424
|
+
const insertPos = cursorPos - diff;
|
|
425
|
+
this.#actualValue = this.#actualValue.substring(0, insertPos) +
|
|
426
|
+
currentDisplayValue.substring(insertPos, cursorPos) +
|
|
427
|
+
this.#actualValue.substring(insertPos);
|
|
428
|
+
}
|
|
429
|
+
else if (newLength < oldLength) {
|
|
430
|
+
// Something was removed (fallback)
|
|
431
|
+
this.#actualValue = this.#actualValue.substring(0, cursorPos) +
|
|
432
|
+
this.#actualValue.substring(cursorPos + (oldLength - newLength));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Now apply masking to the display
|
|
436
|
+
const maskedValue = this.#maskValue(this.#actualValue);
|
|
437
|
+
this.#inputElement.value = maskedValue;
|
|
438
|
+
// Restore cursor position
|
|
439
|
+
this.#inputElement.setSelectionRange(cursorPos, cursorPos);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
// For non-masked inputs, just read the value normally
|
|
443
|
+
this.#actualValue = this.#inputElement.value;
|
|
444
|
+
}
|
|
445
|
+
// Clear previous errors on input (improve UX)
|
|
446
|
+
this.#clearErrors();
|
|
447
|
+
// Sync hidden input for form submission
|
|
448
|
+
this.#syncHiddenInput();
|
|
449
|
+
// Dispatch custom event for parent forms
|
|
450
|
+
this.dispatchEvent(new CustomEvent('secure-input', {
|
|
451
|
+
detail: {
|
|
452
|
+
name: this.#inputElement.name,
|
|
453
|
+
value: this.#actualValue, // Parent can access actual value
|
|
454
|
+
masked: this.#isMasked,
|
|
455
|
+
tier: this.securityTier
|
|
456
|
+
},
|
|
457
|
+
bubbles: true,
|
|
458
|
+
composed: true
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Mask a value based on tier configuration
|
|
463
|
+
*
|
|
464
|
+
* Security Note: For CRITICAL tier, we mask everything. For SENSITIVE tier,
|
|
465
|
+
* we can optionally reveal last few characters (e.g., last 4 digits of phone).
|
|
466
|
+
*
|
|
467
|
+
* @private
|
|
468
|
+
*/
|
|
469
|
+
#maskValue(value) {
|
|
470
|
+
const config = this.config;
|
|
471
|
+
const maskChar = config.masking.character;
|
|
472
|
+
if (!config.masking.partial || this.securityTier === SecurityTier.CRITICAL) {
|
|
473
|
+
// Mask everything
|
|
474
|
+
return maskChar.repeat(value.length);
|
|
475
|
+
}
|
|
476
|
+
// Partial masking: show last 4 characters
|
|
477
|
+
if (value.length <= 4) {
|
|
478
|
+
return maskChar.repeat(value.length);
|
|
479
|
+
}
|
|
480
|
+
const maskedPart = maskChar.repeat(value.length - 4);
|
|
481
|
+
const visiblePart = value.slice(-4);
|
|
482
|
+
return maskedPart + visiblePart;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Validate password strength based on security tier
|
|
486
|
+
*
|
|
487
|
+
* Tier rules:
|
|
488
|
+
* - CRITICAL: uppercase + lowercase + digit + symbol, 8+ chars
|
|
489
|
+
* - SENSITIVE: uppercase + lowercase + digit, 8+ chars
|
|
490
|
+
* - AUTHENTICATED: 6+ chars
|
|
491
|
+
* - PUBLIC: no strength requirement
|
|
492
|
+
*
|
|
493
|
+
* @private
|
|
494
|
+
* @returns null if valid or not a password, error message string if invalid
|
|
495
|
+
*/
|
|
496
|
+
#validatePasswordStrength(value) {
|
|
497
|
+
if (!this.#inputElement || this.#inputElement.type !== 'password') {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
// Skip strength check on empty values — required check handles that
|
|
501
|
+
if (!value || value.length === 0) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
const tier = this.securityTier;
|
|
505
|
+
if (tier === 'critical') {
|
|
506
|
+
if (value.length < 8)
|
|
507
|
+
return 'Password must be at least 8 characters';
|
|
508
|
+
if (!/[a-z]/.test(value))
|
|
509
|
+
return 'Password must include a lowercase letter';
|
|
510
|
+
if (!/[A-Z]/.test(value))
|
|
511
|
+
return 'Password must include an uppercase letter';
|
|
512
|
+
if (!/[0-9]/.test(value))
|
|
513
|
+
return 'Password must include a number';
|
|
514
|
+
if (!/[^a-zA-Z0-9]/.test(value))
|
|
515
|
+
return 'Password must include a special character';
|
|
516
|
+
}
|
|
517
|
+
else if (tier === 'sensitive') {
|
|
518
|
+
if (value.length < 8)
|
|
519
|
+
return 'Password must be at least 8 characters';
|
|
520
|
+
if (!/[a-z]/.test(value))
|
|
521
|
+
return 'Password must include a lowercase letter';
|
|
522
|
+
if (!/[A-Z]/.test(value))
|
|
523
|
+
return 'Password must include an uppercase letter';
|
|
524
|
+
if (!/[0-9]/.test(value))
|
|
525
|
+
return 'Password must include a number';
|
|
526
|
+
}
|
|
527
|
+
else if (tier === 'authenticated') {
|
|
528
|
+
if (value.length < 6)
|
|
529
|
+
return 'Password must be at least 6 characters';
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Validate number input for overflow and safe integer range
|
|
535
|
+
*
|
|
536
|
+
* Prevents JavaScript precision loss by checking against Number.MAX_SAFE_INTEGER.
|
|
537
|
+
* Also enforces min/max attribute constraints.
|
|
538
|
+
*
|
|
539
|
+
* @private
|
|
540
|
+
* @returns null if valid or not a number, error message string if invalid
|
|
541
|
+
*/
|
|
542
|
+
#validateNumberOverflow(value) {
|
|
543
|
+
if (!this.#inputElement || this.#inputElement.type !== 'number') {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
// Skip on empty values — required check handles that
|
|
547
|
+
if (!value || value.length === 0) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
const num = Number(value);
|
|
551
|
+
if (!Number.isFinite(num)) {
|
|
552
|
+
return 'Value must be a valid number';
|
|
553
|
+
}
|
|
554
|
+
// Check safe integer range for integer values (no decimal point)
|
|
555
|
+
if (!value.includes('.') && !Number.isSafeInteger(num)) {
|
|
556
|
+
return 'Value exceeds safe integer range';
|
|
557
|
+
}
|
|
558
|
+
// Enforce min/max attributes
|
|
559
|
+
const minAttr = this.getAttribute('min');
|
|
560
|
+
const maxAttr = this.getAttribute('max');
|
|
561
|
+
if (minAttr !== null) {
|
|
562
|
+
const min = Number(minAttr);
|
|
563
|
+
if (Number.isFinite(min) && num < min) {
|
|
564
|
+
return `Value must be at least ${min}`;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (maxAttr !== null) {
|
|
568
|
+
const max = Number(maxAttr);
|
|
569
|
+
if (Number.isFinite(max) && num > max) {
|
|
570
|
+
return `Value must be at most ${max}`;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Validate the input and show error messages
|
|
577
|
+
*
|
|
578
|
+
* @private
|
|
579
|
+
*/
|
|
580
|
+
#validateAndShowErrors() {
|
|
581
|
+
// Check rate limit first
|
|
582
|
+
const rateLimitCheck = this.checkRateLimit();
|
|
583
|
+
if (!rateLimitCheck.allowed) {
|
|
584
|
+
this.#showError(`Too many attempts. Please wait ${Math.ceil(rateLimitCheck.retryAfter / 1000)} seconds.`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// Perform base validation
|
|
588
|
+
const pattern = this.getAttribute('pattern');
|
|
589
|
+
const minLength = this.getAttribute('minlength');
|
|
590
|
+
const maxLength = this.getAttribute('maxlength');
|
|
591
|
+
const validation = this.validateInput(this.#actualValue, {
|
|
592
|
+
pattern: pattern ? new RegExp(pattern) : null,
|
|
593
|
+
minLength: minLength ? parseInt(minLength, 10) : 0,
|
|
594
|
+
maxLength: maxLength ? parseInt(maxLength, 10) : this.config.validation.maxLength
|
|
595
|
+
});
|
|
596
|
+
if (!validation.valid) {
|
|
597
|
+
this.#showError(validation.errors.join(', '));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// Type-specific validation: password strength
|
|
601
|
+
const passwordError = this.#validatePasswordStrength(this.#actualValue);
|
|
602
|
+
if (passwordError) {
|
|
603
|
+
this.#showError(passwordError);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// Type-specific validation: number overflow
|
|
607
|
+
const numberError = this.#validateNumberOverflow(this.#actualValue);
|
|
608
|
+
if (numberError) {
|
|
609
|
+
this.#showError(numberError);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Show error message
|
|
615
|
+
*
|
|
616
|
+
* @private
|
|
617
|
+
*/
|
|
618
|
+
#showError(message) {
|
|
619
|
+
this.#errorContainer.textContent = message;
|
|
620
|
+
// Force reflow so browser registers the hidden state with content,
|
|
621
|
+
// then remove hidden to trigger the CSS transition
|
|
622
|
+
void this.#errorContainer.offsetHeight;
|
|
623
|
+
this.#errorContainer.classList.remove('hidden');
|
|
624
|
+
this.#inputElement.classList.add('error');
|
|
625
|
+
this.#inputElement.setAttribute('aria-invalid', 'true');
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Clear error messages
|
|
629
|
+
*
|
|
630
|
+
* @private
|
|
631
|
+
*/
|
|
632
|
+
#clearErrors() {
|
|
633
|
+
// Start the hide animation first, clear text only after transition ends
|
|
634
|
+
this.#errorContainer.classList.add('hidden');
|
|
635
|
+
this.#errorContainer.addEventListener('transitionend', () => {
|
|
636
|
+
if (this.#errorContainer.classList.contains('hidden')) {
|
|
637
|
+
this.#errorContainer.textContent = '';
|
|
638
|
+
}
|
|
639
|
+
}, { once: true });
|
|
640
|
+
this.#inputElement.classList.remove('error');
|
|
641
|
+
this.#inputElement.removeAttribute('aria-invalid');
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Set the input value
|
|
645
|
+
*
|
|
646
|
+
* @private
|
|
647
|
+
*/
|
|
648
|
+
#setValue(value) {
|
|
649
|
+
this.#actualValue = value;
|
|
650
|
+
if (this.#isMasked && this.#inputElement.type !== 'password') {
|
|
651
|
+
this.#inputElement.value = this.#maskValue(value);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
this.#inputElement.value = value;
|
|
655
|
+
}
|
|
656
|
+
// Sync hidden input for form submission
|
|
657
|
+
this.#syncHiddenInput();
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Get component-specific styles
|
|
661
|
+
*
|
|
662
|
+
* @private
|
|
663
|
+
*/
|
|
664
|
+
#getComponentStyles() {
|
|
665
|
+
return new URL('./secure-input.css', import.meta.url).href;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Handle attribute changes
|
|
669
|
+
*
|
|
670
|
+
* @protected
|
|
671
|
+
*/
|
|
672
|
+
handleAttributeChange(name, _oldValue, newValue) {
|
|
673
|
+
if (!this.#inputElement)
|
|
674
|
+
return;
|
|
675
|
+
switch (name) {
|
|
676
|
+
case 'disabled':
|
|
677
|
+
this.#inputElement.disabled = this.hasAttribute('disabled');
|
|
678
|
+
break;
|
|
679
|
+
case 'readonly':
|
|
680
|
+
this.#inputElement.readOnly = this.hasAttribute('readonly');
|
|
681
|
+
break;
|
|
682
|
+
case 'value':
|
|
683
|
+
if (newValue !== this.#actualValue) {
|
|
684
|
+
this.#setValue(newValue || '');
|
|
685
|
+
}
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Get the current value (unmasked)
|
|
691
|
+
*
|
|
692
|
+
* Security Note: This exposes the actual value. Only call this when
|
|
693
|
+
* submitting the form or when you have proper authorization.
|
|
694
|
+
*
|
|
695
|
+
* @public
|
|
696
|
+
*/
|
|
697
|
+
get value() {
|
|
698
|
+
return this.#actualValue;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Set the value
|
|
702
|
+
*
|
|
703
|
+
* @public
|
|
704
|
+
*/
|
|
705
|
+
set value(value) {
|
|
706
|
+
this.#setValue(value || '');
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Get the input name
|
|
710
|
+
*
|
|
711
|
+
* @public
|
|
712
|
+
*/
|
|
713
|
+
get name() {
|
|
714
|
+
return this.#inputElement ? this.#inputElement.name : '';
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Check if the input is valid
|
|
718
|
+
*
|
|
719
|
+
* @public
|
|
720
|
+
*/
|
|
721
|
+
get valid() {
|
|
722
|
+
const pattern = this.getAttribute('pattern');
|
|
723
|
+
const minLength = this.getAttribute('minlength');
|
|
724
|
+
const maxLength = this.getAttribute('maxlength');
|
|
725
|
+
const required = this.hasAttribute('required');
|
|
726
|
+
// Check required field first (combines HTML attribute and tier config)
|
|
727
|
+
if (required || this.config.validation.required) {
|
|
728
|
+
if (!this.#actualValue || this.#actualValue.trim().length === 0) {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const validation = this.validateInput(this.#actualValue, {
|
|
733
|
+
pattern: pattern ? new RegExp(pattern) : null,
|
|
734
|
+
minLength: minLength ? parseInt(minLength, 10) : 0,
|
|
735
|
+
maxLength: maxLength ? parseInt(maxLength, 10) : this.config.validation.maxLength
|
|
736
|
+
});
|
|
737
|
+
if (!validation.valid) {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
// Type-specific: password strength
|
|
741
|
+
if (this.#validatePasswordStrength(this.#actualValue) !== null) {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
// Type-specific: number overflow
|
|
745
|
+
if (this.#validateNumberOverflow(this.#actualValue) !== null) {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Focus the input
|
|
752
|
+
*
|
|
753
|
+
* @public
|
|
754
|
+
*/
|
|
755
|
+
focus() {
|
|
756
|
+
if (this.#inputElement) {
|
|
757
|
+
this.#inputElement.focus();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Blur the input
|
|
762
|
+
*
|
|
763
|
+
* @public
|
|
764
|
+
*/
|
|
765
|
+
blur() {
|
|
766
|
+
if (this.#inputElement) {
|
|
767
|
+
this.#inputElement.blur();
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Cleanup on disconnect
|
|
772
|
+
*/
|
|
773
|
+
disconnectedCallback() {
|
|
774
|
+
super.disconnectedCallback();
|
|
775
|
+
// Clear sensitive data from memory
|
|
776
|
+
this.#actualValue = '';
|
|
777
|
+
if (this.#inputElement) {
|
|
778
|
+
this.#inputElement.value = '';
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// Define the custom element
|
|
783
|
+
customElements.define('secure-input', SecureInput);
|
|
784
|
+
export default SecureInput;
|
|
785
|
+
//# sourceMappingURL=secure-input.js.map
|