secure-ui-components 0.2.2 → 0.2.4

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