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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +310 -0
  3. package/dist/components/secure-datetime/secure-datetime.css +263 -0
  4. package/dist/components/secure-datetime/secure-datetime.d.ts +124 -0
  5. package/dist/components/secure-datetime/secure-datetime.d.ts.map +1 -0
  6. package/dist/components/secure-datetime/secure-datetime.js +610 -0
  7. package/dist/components/secure-datetime/secure-datetime.js.map +1 -0
  8. package/dist/components/secure-file-upload/secure-file-upload.css +334 -0
  9. package/dist/components/secure-file-upload/secure-file-upload.d.ts +150 -0
  10. package/dist/components/secure-file-upload/secure-file-upload.d.ts.map +1 -0
  11. package/dist/components/secure-file-upload/secure-file-upload.js +911 -0
  12. package/dist/components/secure-file-upload/secure-file-upload.js.map +1 -0
  13. package/dist/components/secure-form/secure-form.css +62 -0
  14. package/dist/components/secure-form/secure-form.d.ts +128 -0
  15. package/dist/components/secure-form/secure-form.d.ts.map +1 -0
  16. package/dist/components/secure-form/secure-form.js +697 -0
  17. package/dist/components/secure-form/secure-form.js.map +1 -0
  18. package/dist/components/secure-input/secure-input.css +168 -0
  19. package/dist/components/secure-input/secure-input.d.ts +114 -0
  20. package/dist/components/secure-input/secure-input.d.ts.map +1 -0
  21. package/dist/components/secure-input/secure-input.js +785 -0
  22. package/dist/components/secure-input/secure-input.js.map +1 -0
  23. package/dist/components/secure-select/secure-select.css +195 -0
  24. package/dist/components/secure-select/secure-select.d.ts +149 -0
  25. package/dist/components/secure-select/secure-select.d.ts.map +1 -0
  26. package/dist/components/secure-select/secure-select.js +634 -0
  27. package/dist/components/secure-select/secure-select.js.map +1 -0
  28. package/dist/components/secure-submit-button/secure-submit-button.css +135 -0
  29. package/dist/components/secure-submit-button/secure-submit-button.d.ts +61 -0
  30. package/dist/components/secure-submit-button/secure-submit-button.d.ts.map +1 -0
  31. package/dist/components/secure-submit-button/secure-submit-button.js +399 -0
  32. package/dist/components/secure-submit-button/secure-submit-button.js.map +1 -0
  33. package/dist/components/secure-table/secure-table.css +341 -0
  34. package/dist/components/secure-table/secure-table.d.ts +64 -0
  35. package/dist/components/secure-table/secure-table.d.ts.map +1 -0
  36. package/dist/components/secure-table/secure-table.js +567 -0
  37. package/dist/components/secure-table/secure-table.js.map +1 -0
  38. package/dist/components/secure-textarea/secure-textarea.css +153 -0
  39. package/dist/components/secure-textarea/secure-textarea.d.ts +111 -0
  40. package/dist/components/secure-textarea/secure-textarea.d.ts.map +1 -0
  41. package/dist/components/secure-textarea/secure-textarea.js +477 -0
  42. package/dist/components/secure-textarea/secure-textarea.js.map +1 -0
  43. package/dist/core/base-component.d.ts +134 -0
  44. package/dist/core/base-component.d.ts.map +1 -0
  45. package/dist/core/base-component.js +303 -0
  46. package/dist/core/base-component.js.map +1 -0
  47. package/dist/core/base.css +37 -0
  48. package/dist/core/security-config.d.ts +89 -0
  49. package/dist/core/security-config.d.ts.map +1 -0
  50. package/dist/core/security-config.js +273 -0
  51. package/dist/core/security-config.js.map +1 -0
  52. package/dist/core/types.d.ts +212 -0
  53. package/dist/core/types.d.ts.map +1 -0
  54. package/dist/core/types.js +7 -0
  55. package/dist/core/types.js.map +1 -0
  56. package/dist/index.d.ts +18 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +19 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/package.json +89 -0
  61. package/dist/styles/tokens.css +257 -0
  62. 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