secure-ui-components 0.1.1 → 0.1.2

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 (43) hide show
  1. package/README.md +368 -38
  2. package/dist/components/secure-card/secure-card.css +427 -0
  3. package/dist/components/secure-card/secure-card.d.ts +73 -0
  4. package/dist/components/secure-card/secure-card.d.ts.map +1 -0
  5. package/dist/components/secure-card/secure-card.js +787 -0
  6. package/dist/components/secure-card/secure-card.js.map +1 -0
  7. package/dist/components/secure-datetime/secure-datetime.d.ts.map +1 -1
  8. package/dist/components/secure-datetime/secure-datetime.js +6 -19
  9. package/dist/components/secure-datetime/secure-datetime.js.map +1 -1
  10. package/dist/components/secure-file-upload/secure-file-upload.d.ts.map +1 -1
  11. package/dist/components/secure-file-upload/secure-file-upload.js +0 -16
  12. package/dist/components/secure-file-upload/secure-file-upload.js.map +1 -1
  13. package/dist/components/secure-form/secure-form.d.ts.map +1 -1
  14. package/dist/components/secure-form/secure-form.js +126 -6
  15. package/dist/components/secure-form/secure-form.js.map +1 -1
  16. package/dist/components/secure-input/secure-input.d.ts.map +1 -1
  17. package/dist/components/secure-input/secure-input.js +6 -21
  18. package/dist/components/secure-input/secure-input.js.map +1 -1
  19. package/dist/components/secure-select/secure-select.d.ts.map +1 -1
  20. package/dist/components/secure-select/secure-select.js +6 -19
  21. package/dist/components/secure-select/secure-select.js.map +1 -1
  22. package/dist/components/secure-telemetry-provider/secure-telemetry-provider.d.ts +63 -0
  23. package/dist/components/secure-telemetry-provider/secure-telemetry-provider.d.ts.map +1 -0
  24. package/dist/components/secure-telemetry-provider/secure-telemetry-provider.js +198 -0
  25. package/dist/components/secure-telemetry-provider/secure-telemetry-provider.js.map +1 -0
  26. package/dist/components/secure-textarea/secure-textarea.d.ts.map +1 -1
  27. package/dist/components/secure-textarea/secure-textarea.js +6 -19
  28. package/dist/components/secure-textarea/secure-textarea.js.map +1 -1
  29. package/dist/core/base-component.d.ts +23 -1
  30. package/dist/core/base-component.d.ts.map +1 -1
  31. package/dist/core/base-component.js +111 -0
  32. package/dist/core/base-component.js.map +1 -1
  33. package/dist/core/security-config.d.ts.map +1 -1
  34. package/dist/core/security-config.js +5 -21
  35. package/dist/core/security-config.js.map +1 -1
  36. package/dist/core/types.d.ts +129 -8
  37. package/dist/core/types.d.ts.map +1 -1
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/package.json +4 -2
  43. package/package.json +13 -1
@@ -0,0 +1,787 @@
1
+ /**
2
+ * @fileoverview SecureCard — composite credit card input Web Component
3
+ *
4
+ * Renders card number, expiry date, CVC, and optional cardholder name
5
+ * fields inside a closed Shadow DOM with a live 3D card preview.
6
+ *
7
+ * Security model:
8
+ * - Full PAN is never included in events, audit logs, or hidden inputs
9
+ * - CVC is never in events, hidden inputs, or audit logs
10
+ * - Card number masked to last-4 on blur
11
+ * - Security tier is locked to CRITICAL (immutable, fail-secure default)
12
+ * - Luhn validation on card number
13
+ * - Rate limiting and audit logging via SecureBaseComponent
14
+ * - All sensitive fields cleared on disconnectedCallback
15
+ *
16
+ * PCI note: These fields provide a secure UI layer only. Raw card data
17
+ * must be passed to a PCI-compliant payment processor's SDK — never to
18
+ * your own server. Use getCardData() exclusively for SDK tokenisation.
19
+ *
20
+ * @module secure-card
21
+ * @license MIT
22
+ */
23
+ import { SecureBaseComponent } from '../../core/base-component.js';
24
+ // Ordered most-specific first to avoid false positives (e.g. Diners before Visa)
25
+ const CARD_TYPES = Object.freeze([
26
+ {
27
+ type: 'amex',
28
+ pattern: /^3[47]/,
29
+ format: [4, 6, 5],
30
+ cvcLength: 4,
31
+ lengths: [15],
32
+ label: 'Amex',
33
+ },
34
+ {
35
+ type: 'diners',
36
+ pattern: /^3(?:0[0-5]|[68])/,
37
+ format: [4, 6, 4],
38
+ cvcLength: 3,
39
+ lengths: [14],
40
+ label: 'Diners',
41
+ },
42
+ {
43
+ type: 'discover',
44
+ pattern: /^6(?:011|5[0-9]{2})/,
45
+ format: [4, 4, 4, 4],
46
+ cvcLength: 3,
47
+ lengths: [16],
48
+ label: 'Discover',
49
+ },
50
+ {
51
+ type: 'jcb',
52
+ pattern: /^(?:2131|1800|35\d{3})/,
53
+ format: [4, 4, 4, 4],
54
+ cvcLength: 3,
55
+ lengths: [16],
56
+ label: 'JCB',
57
+ },
58
+ {
59
+ type: 'mastercard',
60
+ pattern: /^(?:5[1-5]|2[2-7])/,
61
+ format: [4, 4, 4, 4],
62
+ cvcLength: 3,
63
+ lengths: [16],
64
+ label: 'Mastercard',
65
+ },
66
+ {
67
+ type: 'visa',
68
+ pattern: /^4/,
69
+ format: [4, 4, 4, 4],
70
+ cvcLength: 3,
71
+ lengths: [13, 16, 19],
72
+ label: 'Visa',
73
+ },
74
+ ]);
75
+ // ── Utilities ──────────────────────────────────────────────────────────────
76
+ function detectCardType(digits) {
77
+ if (!digits)
78
+ return null;
79
+ return CARD_TYPES.find(ct => ct.pattern.test(digits)) ?? null;
80
+ }
81
+ function formatDigits(digits, format) {
82
+ let result = '';
83
+ let pos = 0;
84
+ for (let i = 0; i < format.length; i++) {
85
+ const chunk = digits.slice(pos, pos + format[i]);
86
+ if (!chunk)
87
+ break;
88
+ if (i > 0)
89
+ result += ' ';
90
+ result += chunk;
91
+ pos += format[i];
92
+ }
93
+ return result;
94
+ }
95
+ function maskCardForDisplay(digits, format) {
96
+ const totalLen = format.reduce((a, b) => a + b, 0);
97
+ if (!digits) {
98
+ return formatDigits('•'.repeat(totalLen), format);
99
+ }
100
+ const padded = digits.padEnd(totalLen, '•').slice(0, totalLen);
101
+ const last4Start = Math.max(0, padded.length - 4);
102
+ const masked = '•'.repeat(last4Start) + padded.slice(last4Start);
103
+ return formatDigits(masked, format);
104
+ }
105
+ function luhnValid(digits) {
106
+ if (!digits || digits.length < 12)
107
+ return false;
108
+ let sum = 0;
109
+ let alternate = false;
110
+ for (let i = digits.length - 1; i >= 0; i--) {
111
+ const ch = digits[i];
112
+ if (!ch)
113
+ return false;
114
+ const n0 = parseInt(ch, 10);
115
+ if (isNaN(n0))
116
+ return false;
117
+ let n = n0;
118
+ if (alternate) {
119
+ n *= 2;
120
+ if (n > 9)
121
+ n -= 9;
122
+ }
123
+ sum += n;
124
+ alternate = !alternate;
125
+ }
126
+ return sum % 10 === 0;
127
+ }
128
+ function isExpiryValid(month, year) {
129
+ const now = new Date();
130
+ const currentYear = now.getFullYear() % 100;
131
+ const currentMonth = now.getMonth() + 1;
132
+ if (year < currentYear)
133
+ return false;
134
+ if (year === currentYear && month < currentMonth)
135
+ return false;
136
+ return true;
137
+ }
138
+ let instanceCounter = 0;
139
+ // ── Component ──────────────────────────────────────────────────────────────
140
+ export class SecureCard extends SecureBaseComponent {
141
+ // Shadow DOM input elements
142
+ #numberInput = null;
143
+ #expiryInput = null;
144
+ #cvcInput = null;
145
+ #nameInput = null;
146
+ // Card visual elements
147
+ #cardEl = null;
148
+ #cardNumberDisplay = null;
149
+ #cardExpiryDisplay = null;
150
+ #cardNameDisplay = null;
151
+ #cardTypeLabel = null;
152
+ #cardCvcDisplay = null;
153
+ // Error containers
154
+ #numberError = null;
155
+ #expiryError = null;
156
+ #cvcError = null;
157
+ #nameError = null;
158
+ // Light DOM hidden inputs for form participation
159
+ #hiddenNumber = null;
160
+ #hiddenExpiry = null;
161
+ #hiddenName = null;
162
+ // Sensitive state — cleared on disconnect
163
+ #cardDigits = '';
164
+ #cvcDigits = '';
165
+ #expiryValue = '';
166
+ #cardholderName = '';
167
+ #cardTypeConfig = null;
168
+ #instanceId;
169
+ constructor() {
170
+ super();
171
+ this.#instanceId = `secure-card-${++instanceCounter}`;
172
+ }
173
+ static get observedAttributes() {
174
+ return [...super.observedAttributes, 'name', 'label', 'show-name'];
175
+ }
176
+ // ── Render ─────────────────────────────────────────────────────────────────
177
+ render() {
178
+ const fragment = document.createDocumentFragment();
179
+ const disabled = this.hasAttribute('disabled');
180
+ const showName = this.hasAttribute('show-name');
181
+ const label = this.getAttribute('label') ?? '';
182
+ // ── Outer container ─────────────────────────────────────────────────────
183
+ const container = document.createElement('div');
184
+ container.className = 'card-container';
185
+ container.setAttribute('part', 'container');
186
+ // ── Card visual (aria-hidden — purely decorative) ────────────────────────
187
+ const scene = document.createElement('div');
188
+ scene.className = 'card-scene';
189
+ scene.setAttribute('aria-hidden', 'true');
190
+ this.#cardEl = document.createElement('div');
191
+ this.#cardEl.className = 'card';
192
+ // Front face
193
+ const front = document.createElement('div');
194
+ front.className = 'card-face card-front';
195
+ const topRow = document.createElement('div');
196
+ topRow.className = 'card-top-row';
197
+ const chip = document.createElement('div');
198
+ chip.className = 'card-chip';
199
+ this.#cardTypeLabel = document.createElement('div');
200
+ this.#cardTypeLabel.className = 'card-type-label';
201
+ topRow.appendChild(chip);
202
+ topRow.appendChild(this.#cardTypeLabel);
203
+ front.appendChild(topRow);
204
+ this.#cardNumberDisplay = document.createElement('div');
205
+ this.#cardNumberDisplay.className = 'card-number-display';
206
+ this.#cardNumberDisplay.textContent = '•••• •••• •••• ••••';
207
+ front.appendChild(this.#cardNumberDisplay);
208
+ const bottomRow = document.createElement('div');
209
+ bottomRow.className = 'card-bottom-row';
210
+ const nameSection = document.createElement('div');
211
+ nameSection.className = 'card-name-section';
212
+ const nameFieldLabel = document.createElement('div');
213
+ nameFieldLabel.className = 'card-field-label';
214
+ nameFieldLabel.textContent = 'Card holder';
215
+ this.#cardNameDisplay = document.createElement('div');
216
+ this.#cardNameDisplay.className = 'card-name-display';
217
+ this.#cardNameDisplay.textContent = 'FULL NAME';
218
+ nameSection.appendChild(nameFieldLabel);
219
+ nameSection.appendChild(this.#cardNameDisplay);
220
+ const expirySection = document.createElement('div');
221
+ expirySection.className = 'card-expiry-section';
222
+ const expiryFieldLabel = document.createElement('div');
223
+ expiryFieldLabel.className = 'card-field-label';
224
+ expiryFieldLabel.textContent = 'Expires';
225
+ this.#cardExpiryDisplay = document.createElement('div');
226
+ this.#cardExpiryDisplay.className = 'card-expiry-display';
227
+ this.#cardExpiryDisplay.textContent = 'MM/YY';
228
+ expirySection.appendChild(expiryFieldLabel);
229
+ expirySection.appendChild(this.#cardExpiryDisplay);
230
+ bottomRow.appendChild(nameSection);
231
+ bottomRow.appendChild(expirySection);
232
+ front.appendChild(bottomRow);
233
+ // Back face
234
+ const back = document.createElement('div');
235
+ back.className = 'card-face card-back';
236
+ const strip = document.createElement('div');
237
+ strip.className = 'card-strip';
238
+ const cvcArea = document.createElement('div');
239
+ cvcArea.className = 'card-cvc-area';
240
+ const cvcAreaLabel = document.createElement('div');
241
+ cvcAreaLabel.className = 'card-cvc-label';
242
+ cvcAreaLabel.textContent = 'CVC';
243
+ this.#cardCvcDisplay = document.createElement('div');
244
+ this.#cardCvcDisplay.className = 'card-cvc-display';
245
+ this.#cardCvcDisplay.textContent = '•••';
246
+ cvcArea.appendChild(cvcAreaLabel);
247
+ cvcArea.appendChild(this.#cardCvcDisplay);
248
+ back.appendChild(strip);
249
+ back.appendChild(cvcArea);
250
+ this.#cardEl.appendChild(front);
251
+ this.#cardEl.appendChild(back);
252
+ scene.appendChild(this.#cardEl);
253
+ // ── Input fields ──────────────────────────────────────────────────────────
254
+ const fields = document.createElement('div');
255
+ fields.className = 'card-fields';
256
+ if (label) {
257
+ const legend = document.createElement('div');
258
+ legend.className = 'card-legend';
259
+ legend.setAttribute('part', 'label');
260
+ legend.textContent = this.sanitizeValue(label);
261
+ fields.appendChild(legend);
262
+ }
263
+ // Card number
264
+ const numberGroup = this.#makeFieldGroup(`${this.#instanceId}-number`);
265
+ const numberLabel = numberGroup.querySelector('label');
266
+ numberLabel.textContent = 'Card number';
267
+ this.#numberInput = document.createElement('input');
268
+ this.#numberInput.id = `${this.#instanceId}-number`;
269
+ this.#numberInput.className = 'card-input card-number-input';
270
+ this.#numberInput.setAttribute('type', 'text');
271
+ this.#numberInput.setAttribute('inputmode', 'numeric');
272
+ this.#numberInput.setAttribute('autocomplete', 'cc-number');
273
+ this.#numberInput.setAttribute('placeholder', '0000 0000 0000 0000');
274
+ this.#numberInput.setAttribute('maxlength', '23');
275
+ this.#numberInput.setAttribute('aria-required', 'true');
276
+ this.#numberInput.setAttribute('aria-describedby', `${this.#instanceId}-number-error`);
277
+ this.#numberInput.setAttribute('part', 'number-input');
278
+ if (disabled)
279
+ this.#numberInput.disabled = true;
280
+ this.#numberError = numberGroup.querySelector('.error-container');
281
+ this.#numberError.id = `${this.#instanceId}-number-error`;
282
+ numberGroup.querySelector('.input-wrapper').appendChild(this.#numberInput);
283
+ fields.appendChild(numberGroup);
284
+ // Expiry + CVC side by side
285
+ const fieldRow = document.createElement('div');
286
+ fieldRow.className = 'field-row';
287
+ const expiryGroup = this.#makeFieldGroup(`${this.#instanceId}-expiry`);
288
+ expiryGroup.querySelector('label').textContent = 'Expiry date';
289
+ this.#expiryInput = document.createElement('input');
290
+ this.#expiryInput.id = `${this.#instanceId}-expiry`;
291
+ this.#expiryInput.className = 'card-input';
292
+ this.#expiryInput.setAttribute('type', 'text');
293
+ this.#expiryInput.setAttribute('inputmode', 'numeric');
294
+ this.#expiryInput.setAttribute('autocomplete', 'cc-exp');
295
+ this.#expiryInput.setAttribute('placeholder', 'MM/YY');
296
+ this.#expiryInput.setAttribute('maxlength', '5');
297
+ this.#expiryInput.setAttribute('aria-required', 'true');
298
+ this.#expiryInput.setAttribute('aria-describedby', `${this.#instanceId}-expiry-error`);
299
+ this.#expiryInput.setAttribute('part', 'expiry-input');
300
+ if (disabled)
301
+ this.#expiryInput.disabled = true;
302
+ this.#expiryError = expiryGroup.querySelector('.error-container');
303
+ this.#expiryError.id = `${this.#instanceId}-expiry-error`;
304
+ expiryGroup.querySelector('.input-wrapper').appendChild(this.#expiryInput);
305
+ const cvcGroup = this.#makeFieldGroup(`${this.#instanceId}-cvc`);
306
+ cvcGroup.querySelector('label').textContent = 'Security code';
307
+ this.#cvcInput = document.createElement('input');
308
+ this.#cvcInput.id = `${this.#instanceId}-cvc`;
309
+ this.#cvcInput.className = 'card-input cvc-input';
310
+ // type=password: browser masks natively, avoids screen capture of CVC
311
+ this.#cvcInput.setAttribute('type', 'password');
312
+ this.#cvcInput.setAttribute('inputmode', 'numeric');
313
+ this.#cvcInput.setAttribute('autocomplete', 'cc-csc');
314
+ this.#cvcInput.setAttribute('placeholder', '•••');
315
+ this.#cvcInput.setAttribute('maxlength', '4');
316
+ this.#cvcInput.setAttribute('aria-required', 'true');
317
+ this.#cvcInput.setAttribute('aria-describedby', `${this.#instanceId}-cvc-error`);
318
+ this.#cvcInput.setAttribute('part', 'cvc-input');
319
+ if (disabled)
320
+ this.#cvcInput.disabled = true;
321
+ this.#cvcError = cvcGroup.querySelector('.error-container');
322
+ this.#cvcError.id = `${this.#instanceId}-cvc-error`;
323
+ cvcGroup.querySelector('.input-wrapper').appendChild(this.#cvcInput);
324
+ fieldRow.appendChild(expiryGroup);
325
+ fieldRow.appendChild(cvcGroup);
326
+ fields.appendChild(fieldRow);
327
+ // Cardholder name (optional)
328
+ const nameGroup = this.#makeFieldGroup(`${this.#instanceId}-name`);
329
+ nameGroup.id = `${this.#instanceId}-name-group`;
330
+ nameGroup.querySelector('label').textContent = 'Cardholder name';
331
+ if (!showName)
332
+ nameGroup.hidden = true;
333
+ this.#nameInput = document.createElement('input');
334
+ this.#nameInput.id = `${this.#instanceId}-name`;
335
+ this.#nameInput.className = 'card-input';
336
+ this.#nameInput.setAttribute('type', 'text');
337
+ this.#nameInput.setAttribute('autocomplete', 'cc-name');
338
+ this.#nameInput.setAttribute('placeholder', 'Name as it appears on card');
339
+ this.#nameInput.setAttribute('spellcheck', 'false');
340
+ this.#nameInput.setAttribute('aria-describedby', `${this.#instanceId}-name-error`);
341
+ this.#nameInput.setAttribute('part', 'name-input');
342
+ if (disabled)
343
+ this.#nameInput.disabled = true;
344
+ this.#nameError = nameGroup.querySelector('.error-container');
345
+ this.#nameError.id = `${this.#instanceId}-name-error`;
346
+ nameGroup.querySelector('.input-wrapper').appendChild(this.#nameInput);
347
+ fields.appendChild(nameGroup);
348
+ container.appendChild(scene);
349
+ container.appendChild(fields);
350
+ // ── Light DOM hidden inputs ───────────────────────────────────────────────
351
+ this.#createHiddenInputs();
352
+ // ── Component styles ──────────────────────────────────────────────────────
353
+ this.addComponentStyles(new URL('./secure-card.css', import.meta.url).href);
354
+ // ── Event listeners ───────────────────────────────────────────────────────
355
+ // Telemetry hooks aggregate signals across all card inputs into one
356
+ // composite behavioral fingerprint for the overall card interaction.
357
+ this.#numberInput.addEventListener('input', (e) => { this.recordTelemetryInput(e); this.#handleNumberInput(e); });
358
+ this.#numberInput.addEventListener('focus', () => { this.recordTelemetryFocus(); this.#handleNumberFocus(); });
359
+ this.#numberInput.addEventListener('blur', () => { this.recordTelemetryBlur(); this.#handleNumberBlur(); });
360
+ this.#expiryInput.addEventListener('input', (e) => { this.recordTelemetryInput(e); this.#handleExpiryInput(e); });
361
+ this.#expiryInput.addEventListener('focus', () => { this.recordTelemetryFocus(); this.#flipCard(false); });
362
+ this.#expiryInput.addEventListener('blur', () => { this.recordTelemetryBlur(); this.#handleExpiryBlur(); });
363
+ this.#cvcInput.addEventListener('input', (e) => { this.recordTelemetryInput(e); this.#handleCvcInput(e); });
364
+ this.#cvcInput.addEventListener('focus', () => { this.recordTelemetryFocus(); this.#flipCard(true); });
365
+ this.#cvcInput.addEventListener('blur', () => { this.recordTelemetryBlur(); this.#handleCvcBlur(); });
366
+ this.#nameInput.addEventListener('input', (e) => { this.recordTelemetryInput(e); this.#handleNameInput(e); });
367
+ this.#nameInput.addEventListener('focus', () => { this.recordTelemetryFocus(); this.#flipCard(false); });
368
+ this.#nameInput.addEventListener('blur', () => { this.recordTelemetryBlur(); this.#handleNameBlur(); });
369
+ fragment.appendChild(container);
370
+ return fragment;
371
+ }
372
+ // ── Field group builder ────────────────────────────────────────────────────
373
+ #makeFieldGroup(id) {
374
+ const group = document.createElement('div');
375
+ group.className = 'field-group';
376
+ const lbl = document.createElement('label');
377
+ lbl.htmlFor = id;
378
+ lbl.className = 'field-label';
379
+ const wrapper = document.createElement('div');
380
+ wrapper.className = 'input-wrapper';
381
+ wrapper.setAttribute('part', 'wrapper');
382
+ const err = document.createElement('div');
383
+ err.className = 'error-container hidden';
384
+ err.setAttribute('role', 'alert');
385
+ err.setAttribute('part', 'error');
386
+ group.appendChild(lbl);
387
+ group.appendChild(wrapper);
388
+ group.appendChild(err);
389
+ return group;
390
+ }
391
+ // ── Attribute change handler ───────────────────────────────────────────────
392
+ handleAttributeChange(name, _oldValue, newValue) {
393
+ if (!this.shadowRoot)
394
+ return;
395
+ switch (name) {
396
+ case 'disabled': {
397
+ const d = newValue !== null;
398
+ if (this.#numberInput)
399
+ this.#numberInput.disabled = d;
400
+ if (this.#expiryInput)
401
+ this.#expiryInput.disabled = d;
402
+ if (this.#cvcInput)
403
+ this.#cvcInput.disabled = d;
404
+ if (this.#nameInput)
405
+ this.#nameInput.disabled = d;
406
+ break;
407
+ }
408
+ case 'show-name': {
409
+ const group = this.shadowRoot.querySelector(`#${this.#instanceId}-name-group`);
410
+ if (group)
411
+ group.hidden = newValue === null;
412
+ break;
413
+ }
414
+ }
415
+ }
416
+ // ── Card number handlers ───────────────────────────────────────────────────
417
+ #handleNumberInput(e) {
418
+ const input = e.target;
419
+ const digits = input.value.replace(/\D/g, '');
420
+ const prevType = this.#cardTypeConfig?.type;
421
+ this.#cardTypeConfig = detectCardType(digits);
422
+ const format = this.#cardTypeConfig?.format ?? [4, 4, 4, 4];
423
+ const maxLen = format.reduce((a, b) => a + b, 0);
424
+ this.#cardDigits = digits.slice(0, maxLen);
425
+ input.value = formatDigits(this.#cardDigits, format);
426
+ // Update CVC length when card type changes
427
+ if (this.#cardTypeConfig?.type !== prevType) {
428
+ const maxWithSpaces = maxLen + format.length - 1;
429
+ input.setAttribute('maxlength', String(maxWithSpaces));
430
+ const cvcLen = this.#cardTypeConfig?.cvcLength ?? 3;
431
+ if (this.#cvcInput) {
432
+ this.#cvcInput.setAttribute('maxlength', String(cvcLen));
433
+ }
434
+ this.#updateCardType();
435
+ }
436
+ // Update card visual front face
437
+ if (this.#cardNumberDisplay) {
438
+ this.#cardNumberDisplay.textContent =
439
+ input.value || formatDigits('•'.repeat(maxLen), format);
440
+ }
441
+ this.#clearError(this.#numberError);
442
+ this.#syncHiddenInputs();
443
+ this.#dispatchChangeEvent();
444
+ }
445
+ #handleNumberFocus() {
446
+ // Restore formatted digits on focus (undo blur masking)
447
+ if (this.#numberInput && this.#cardDigits) {
448
+ const format = this.#cardTypeConfig?.format ?? [4, 4, 4, 4];
449
+ const formatted = formatDigits(this.#cardDigits, format);
450
+ this.#numberInput.value = formatted;
451
+ if (this.#cardNumberDisplay)
452
+ this.#cardNumberDisplay.textContent = formatted;
453
+ }
454
+ this.#flipCard(false);
455
+ this.#clearError(this.#numberError);
456
+ }
457
+ #handleNumberBlur() {
458
+ const rl = this.checkRateLimit();
459
+ if (!rl.allowed) {
460
+ this.#showError(this.#numberError, `Too many attempts. Try again in ${Math.ceil(rl.retryAfter / 1000)}s.`);
461
+ return;
462
+ }
463
+ if (!this.#cardDigits) {
464
+ this.#showError(this.#numberError, 'Card number is required');
465
+ this.#numberInput?.setAttribute('aria-invalid', 'true');
466
+ }
467
+ else if (!luhnValid(this.#cardDigits)) {
468
+ this.#showError(this.#numberError, 'Invalid card number');
469
+ this.#numberInput?.setAttribute('aria-invalid', 'true');
470
+ }
471
+ else {
472
+ this.#clearError(this.#numberError);
473
+ this.#numberInput?.removeAttribute('aria-invalid');
474
+ }
475
+ // Mask middle digits on blur — last 4 remain visible
476
+ this.#applyNumberMask();
477
+ this.audit('card-number-blur', { cardType: this.#cardTypeConfig?.type ?? 'unknown' });
478
+ }
479
+ #applyNumberMask() {
480
+ const format = this.#cardTypeConfig?.format ?? [4, 4, 4, 4];
481
+ const masked = maskCardForDisplay(this.#cardDigits, format);
482
+ if (this.#numberInput)
483
+ this.#numberInput.value = masked;
484
+ if (this.#cardNumberDisplay)
485
+ this.#cardNumberDisplay.textContent = masked;
486
+ }
487
+ // ── Expiry handlers ───────────────────────────────────────────────────────
488
+ #handleExpiryInput(e) {
489
+ const input = e.target;
490
+ const digits = input.value.replace(/\D/g, '').slice(0, 4);
491
+ const formatted = digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits;
492
+ input.value = formatted;
493
+ this.#expiryValue = formatted;
494
+ if (this.#cardExpiryDisplay) {
495
+ this.#cardExpiryDisplay.textContent = formatted || 'MM/YY';
496
+ }
497
+ this.#clearError(this.#expiryError);
498
+ this.#syncHiddenInputs();
499
+ this.#dispatchChangeEvent();
500
+ }
501
+ #handleExpiryBlur() {
502
+ const parts = this.#expiryValue.split('/');
503
+ if (!this.#expiryValue) {
504
+ this.#showError(this.#expiryError, 'Expiry date is required');
505
+ this.#expiryInput?.setAttribute('aria-invalid', 'true');
506
+ return;
507
+ }
508
+ const mm = parseInt(parts[0] ?? '', 10);
509
+ const yy = parseInt(parts[1] ?? '', 10);
510
+ if (isNaN(mm) || isNaN(yy) || mm < 1 || mm > 12 || (parts[1] ?? '').length < 2) {
511
+ this.#showError(this.#expiryError, 'Enter a valid expiry date (MM/YY)');
512
+ this.#expiryInput?.setAttribute('aria-invalid', 'true');
513
+ }
514
+ else if (!isExpiryValid(mm, yy)) {
515
+ this.#showError(this.#expiryError, 'This card has expired');
516
+ this.#expiryInput?.setAttribute('aria-invalid', 'true');
517
+ }
518
+ else {
519
+ this.#clearError(this.#expiryError);
520
+ this.#expiryInput?.removeAttribute('aria-invalid');
521
+ }
522
+ this.audit('card-expiry-blur', {});
523
+ }
524
+ // ── CVC handlers ──────────────────────────────────────────────────────────
525
+ #handleCvcInput(e) {
526
+ const input = e.target;
527
+ const digits = input.value.replace(/\D/g, '');
528
+ const maxLen = this.#cardTypeConfig?.cvcLength ?? 3;
529
+ this.#cvcDigits = digits.slice(0, maxLen);
530
+ input.value = this.#cvcDigits;
531
+ // Card back shows bullet count matching typed length — value never shown
532
+ if (this.#cardCvcDisplay) {
533
+ this.#cardCvcDisplay.textContent =
534
+ this.#cvcDigits.length > 0 ? '•'.repeat(this.#cvcDigits.length) : '•••';
535
+ }
536
+ this.#clearError(this.#cvcError);
537
+ this.#dispatchChangeEvent();
538
+ }
539
+ #handleCvcBlur() {
540
+ const maxLen = this.#cardTypeConfig?.cvcLength ?? 3;
541
+ if (!this.#cvcDigits) {
542
+ this.#showError(this.#cvcError, 'Security code is required');
543
+ this.#cvcInput?.setAttribute('aria-invalid', 'true');
544
+ }
545
+ else if (this.#cvcDigits.length < maxLen) {
546
+ this.#showError(this.#cvcError, `Security code must be ${maxLen} digits`);
547
+ this.#cvcInput?.setAttribute('aria-invalid', 'true');
548
+ }
549
+ else {
550
+ this.#clearError(this.#cvcError);
551
+ this.#cvcInput?.removeAttribute('aria-invalid');
552
+ }
553
+ this.#flipCard(false);
554
+ // CVC value is never audited
555
+ this.audit('card-cvc-blur', {});
556
+ }
557
+ // ── Name handlers ─────────────────────────────────────────────────────────
558
+ #handleNameInput(e) {
559
+ const input = e.target;
560
+ // Letters, spaces, hyphens, apostrophes only
561
+ const sanitized = input.value.replace(/[^a-zA-Z\s\-']/g, '').slice(0, 50);
562
+ input.value = sanitized;
563
+ this.#cardholderName = sanitized;
564
+ if (this.#cardNameDisplay) {
565
+ this.#cardNameDisplay.textContent = sanitized.toUpperCase() || 'FULL NAME';
566
+ }
567
+ this.#clearError(this.#nameError);
568
+ this.#syncHiddenInputs();
569
+ this.#dispatchChangeEvent();
570
+ }
571
+ #handleNameBlur() {
572
+ if (!this.hasAttribute('show-name'))
573
+ return;
574
+ if (!this.#cardholderName.trim()) {
575
+ this.#showError(this.#nameError, 'Cardholder name is required');
576
+ this.#nameInput?.setAttribute('aria-invalid', 'true');
577
+ }
578
+ else {
579
+ this.#clearError(this.#nameError);
580
+ this.#nameInput?.removeAttribute('aria-invalid');
581
+ }
582
+ }
583
+ // ── Card visual ───────────────────────────────────────────────────────────
584
+ #flipCard(toBack) {
585
+ this.#cardEl?.classList.toggle('is-flipped', toBack);
586
+ }
587
+ #updateCardType() {
588
+ if (!this.#cardEl)
589
+ return;
590
+ for (const ct of CARD_TYPES) {
591
+ this.#cardEl.classList.remove(`card--${ct.type}`);
592
+ }
593
+ const type = this.#cardTypeConfig?.type;
594
+ if (type)
595
+ this.#cardEl.classList.add(`card--${type}`);
596
+ if (this.#cardTypeLabel) {
597
+ this.#cardTypeLabel.textContent = this.#cardTypeConfig?.label ?? '';
598
+ }
599
+ }
600
+ // ── Error helpers ─────────────────────────────────────────────────────────
601
+ #showError(container, message) {
602
+ if (!container)
603
+ return;
604
+ container.textContent = '';
605
+ const span = document.createElement('span');
606
+ span.textContent = message;
607
+ container.appendChild(span);
608
+ container.classList.remove('hidden');
609
+ }
610
+ #clearError(container) {
611
+ if (!container)
612
+ return;
613
+ container.textContent = '';
614
+ container.classList.add('hidden');
615
+ }
616
+ // ── Hidden inputs for light DOM form participation ─────────────────────────
617
+ #createHiddenInputs() {
618
+ const fieldName = this.getAttribute('name');
619
+ if (!fieldName)
620
+ return;
621
+ // Card number: stores last4 only — full PAN must never reach your server
622
+ this.#hiddenNumber = document.createElement('input');
623
+ this.#hiddenNumber.type = 'hidden';
624
+ this.#hiddenNumber.name = fieldName;
625
+ this.appendChild(this.#hiddenNumber);
626
+ this.#hiddenExpiry = document.createElement('input');
627
+ this.#hiddenExpiry.type = 'hidden';
628
+ this.#hiddenExpiry.name = `${fieldName}-expiry`;
629
+ this.appendChild(this.#hiddenExpiry);
630
+ this.#hiddenName = document.createElement('input');
631
+ this.#hiddenName.type = 'hidden';
632
+ this.#hiddenName.name = `${fieldName}-holder`;
633
+ this.appendChild(this.#hiddenName);
634
+ // No hidden input for CVC — never submit CVC to your own server
635
+ }
636
+ #syncHiddenInputs() {
637
+ if (this.#hiddenNumber)
638
+ this.#hiddenNumber.value = this.#cardDigits.slice(-4);
639
+ if (this.#hiddenExpiry)
640
+ this.#hiddenExpiry.value = this.#expiryValue;
641
+ if (this.#hiddenName)
642
+ this.#hiddenName.value = this.#cardholderName;
643
+ }
644
+ // ── Event dispatch ────────────────────────────────────────────────────────
645
+ #dispatchChangeEvent() {
646
+ const [rawMonth, rawYear] = this.#expiryValue.split('/');
647
+ this.dispatchEvent(new CustomEvent('secure-card', {
648
+ bubbles: true,
649
+ composed: true,
650
+ detail: {
651
+ name: this.getAttribute('name') ?? '',
652
+ cardType: this.#cardTypeConfig?.type ?? 'unknown',
653
+ last4: this.#cardDigits.slice(-4),
654
+ expiryMonth: parseInt(rawMonth ?? '0', 10) || 0,
655
+ expiryYear: parseInt(rawYear ?? '0', 10) || 0,
656
+ cardholderName: this.#cardholderName,
657
+ valid: this.valid,
658
+ tier: this.securityTier,
659
+ // Full PAN and CVC are intentionally absent — use getCardData() for SDK calls
660
+ },
661
+ }));
662
+ }
663
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
664
+ disconnectedCallback() {
665
+ // Wipe all sensitive state from memory
666
+ this.#cardDigits = '';
667
+ this.#cvcDigits = '';
668
+ this.#expiryValue = '';
669
+ this.#cardholderName = '';
670
+ if (this.#numberInput)
671
+ this.#numberInput.value = '';
672
+ if (this.#expiryInput)
673
+ this.#expiryInput.value = '';
674
+ if (this.#cvcInput)
675
+ this.#cvcInput.value = '';
676
+ if (this.#nameInput)
677
+ this.#nameInput.value = '';
678
+ if (this.#hiddenNumber)
679
+ this.#hiddenNumber.value = '';
680
+ if (this.#hiddenExpiry)
681
+ this.#hiddenExpiry.value = '';
682
+ if (this.#hiddenName)
683
+ this.#hiddenName.value = '';
684
+ super.disconnectedCallback();
685
+ }
686
+ // ── Public API ────────────────────────────────────────────────────────────
687
+ /**
688
+ * True when all visible, required fields pass validation.
689
+ */
690
+ get valid() {
691
+ const numberOk = this.#cardDigits.length >= 12 && luhnValid(this.#cardDigits);
692
+ const [mm, yy] = this.#expiryValue.split('/');
693
+ const month = parseInt(mm ?? '', 10);
694
+ const year = parseInt(yy ?? '', 10);
695
+ const expiryOk = !isNaN(month) &&
696
+ !isNaN(year) &&
697
+ month >= 1 &&
698
+ month <= 12 &&
699
+ (yy ?? '').length >= 2 &&
700
+ isExpiryValid(month, year);
701
+ const cvcLen = this.#cardTypeConfig?.cvcLength ?? 3;
702
+ const cvcOk = this.#cvcDigits.length === cvcLen;
703
+ const nameOk = !this.hasAttribute('show-name') || this.#cardholderName.trim().length > 0;
704
+ return numberOk && expiryOk && cvcOk && nameOk;
705
+ }
706
+ /**
707
+ * Card type detected from the entered number prefix.
708
+ */
709
+ get cardType() {
710
+ return this.#cardTypeConfig?.type ?? 'unknown';
711
+ }
712
+ /**
713
+ * Last 4 digits of the entered card number. Safe to display and log.
714
+ */
715
+ get last4() {
716
+ return this.#cardDigits.slice(-4);
717
+ }
718
+ /**
719
+ * The name attribute value.
720
+ */
721
+ get name() {
722
+ return this.getAttribute('name') ?? '';
723
+ }
724
+ /**
725
+ * Returns raw card data for immediate handoff to a payment SDK tokeniser.
726
+ *
727
+ * SECURITY: Pass this data only to a PCI-compliant processor's client SDK
728
+ * (e.g. Stripe.js createToken, Braintree tokenizeCard). Never send raw card
729
+ * numbers or CVCs to your own server.
730
+ *
731
+ * Returns null if the form is not yet valid.
732
+ */
733
+ getCardData() {
734
+ if (!this.valid)
735
+ return null;
736
+ return {
737
+ number: this.#cardDigits,
738
+ expiry: this.#expiryValue,
739
+ cvc: this.#cvcDigits,
740
+ name: this.#cardholderName,
741
+ };
742
+ }
743
+ /**
744
+ * Clears all fields and resets component state.
745
+ */
746
+ reset() {
747
+ this.#cardDigits = '';
748
+ this.#cvcDigits = '';
749
+ this.#expiryValue = '';
750
+ this.#cardholderName = '';
751
+ this.#cardTypeConfig = null;
752
+ if (this.#numberInput)
753
+ this.#numberInput.value = '';
754
+ if (this.#expiryInput)
755
+ this.#expiryInput.value = '';
756
+ if (this.#cvcInput)
757
+ this.#cvcInput.value = '';
758
+ if (this.#nameInput)
759
+ this.#nameInput.value = '';
760
+ this.#syncHiddenInputs();
761
+ this.#flipCard(false);
762
+ this.#updateCardType();
763
+ const defaultFormat = [4, 4, 4, 4];
764
+ if (this.#cardNumberDisplay) {
765
+ this.#cardNumberDisplay.textContent = formatDigits('•'.repeat(16), defaultFormat);
766
+ }
767
+ if (this.#cardExpiryDisplay)
768
+ this.#cardExpiryDisplay.textContent = 'MM/YY';
769
+ if (this.#cardNameDisplay)
770
+ this.#cardNameDisplay.textContent = 'FULL NAME';
771
+ if (this.#cardCvcDisplay)
772
+ this.#cardCvcDisplay.textContent = '•••';
773
+ this.#clearError(this.#numberError);
774
+ this.#clearError(this.#expiryError);
775
+ this.#clearError(this.#cvcError);
776
+ this.#clearError(this.#nameError);
777
+ this.audit('card-reset', {});
778
+ }
779
+ /**
780
+ * Focuses the card number input.
781
+ */
782
+ focus() {
783
+ this.#numberInput?.focus();
784
+ }
785
+ }
786
+ customElements.define('secure-card', SecureCard);
787
+ //# sourceMappingURL=secure-card.js.map