@sparkvault/sdk 1.0.0 → 1.1.5

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.
@@ -726,7 +726,18 @@ class IdentityApi {
726
726
  * Start passkey registration
727
727
  */
728
728
  async startPasskeyRegister(email) {
729
- return this.request('POST', '/passkey/register', { email });
729
+ // Backend returns { options: PublicKeyCredentialCreationOptions, session: {...} }
730
+ // Extract and flatten to match PasskeyChallengeResponse
731
+ const response = await this.request('POST', '/passkey/register', { email });
732
+ return {
733
+ challenge: response.options.challenge,
734
+ rpId: response.options.rp.id,
735
+ rpName: response.options.rp.name,
736
+ userId: response.options.user.id,
737
+ userName: response.options.user.name,
738
+ timeout: response.options.timeout,
739
+ session: response.session,
740
+ };
730
741
  }
731
742
  /**
732
743
  * Complete passkey registration
@@ -734,7 +745,7 @@ class IdentityApi {
734
745
  async completePasskeyRegister(params) {
735
746
  const attestation = params.credential.response;
736
747
  return this.request('POST', '/passkey/register/complete', {
737
- email: params.email,
748
+ session: params.session,
738
749
  credential: {
739
750
  id: params.credential.id,
740
751
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -750,7 +761,17 @@ class IdentityApi {
750
761
  * Start passkey verification
751
762
  */
752
763
  async startPasskeyVerify(email) {
753
- return this.request('POST', '/passkey/verify', { email });
764
+ // Backend returns { options: PublicKeyCredentialRequestOptions, session: {...} }
765
+ // Extract and flatten to match PasskeyChallengeResponse
766
+ const response = await this.request('POST', '/passkey/verify', { email });
767
+ return {
768
+ challenge: response.options.challenge,
769
+ rpId: response.options.rpId,
770
+ rpName: 'SparkVault Identity', // Not returned by verify endpoint
771
+ timeout: response.options.timeout,
772
+ allowCredentials: response.options.allowCredentials,
773
+ session: response.session,
774
+ };
754
775
  }
755
776
  /**
756
777
  * Complete passkey verification
@@ -758,7 +779,7 @@ class IdentityApi {
758
779
  async completePasskeyVerify(params) {
759
780
  const assertion = params.credential.response;
760
781
  return this.request('POST', '/passkey/verify/complete', {
761
- email: params.email,
782
+ session: params.session,
762
783
  credential: {
763
784
  id: params.credential.id,
764
785
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -795,12 +816,16 @@ class IdentityApi {
795
816
  return `${this.baseUrl}/saml/${provider}?${params}`;
796
817
  }
797
818
  /**
798
- * Send SparkLink email for identity verification
819
+ * Send SparkLink email for identity verification.
820
+ * Includes openerOrigin for postMessage-based completion notification.
799
821
  */
800
822
  async sendSparkLink(email) {
801
823
  return this.request('POST', '/sparklink/send', {
802
824
  email,
803
825
  type: 'verify_identity',
826
+ // Send opener origin for postMessage on verification completion
827
+ // This allows the ceremony page to notify the SDK directly instead of polling
828
+ openerOrigin: typeof window !== 'undefined' ? window.location.origin : undefined,
804
829
  });
805
830
  }
806
831
  /**
@@ -1243,15 +1268,15 @@ class PasskeyHandler {
1243
1268
  */
1244
1269
  async register() {
1245
1270
  try {
1246
- const challenge = await this.api.startPasskeyRegister(this.state.recipient);
1271
+ const challengeResponse = await this.api.startPasskeyRegister(this.state.recipient);
1247
1272
  const publicKeyOptions = {
1248
- challenge: base64urlToArrayBuffer(challenge.challenge),
1273
+ challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1249
1274
  rp: {
1250
- id: challenge.rpId,
1251
- name: challenge.rpName,
1275
+ id: challengeResponse.rpId,
1276
+ name: challengeResponse.rpName,
1252
1277
  },
1253
1278
  user: {
1254
- id: base64urlToArrayBuffer(challenge.userId ?? this.state.recipient),
1279
+ id: base64urlToArrayBuffer(challengeResponse.userId ?? this.state.recipient),
1255
1280
  name: this.state.recipient,
1256
1281
  displayName: this.state.recipient,
1257
1282
  },
@@ -1259,7 +1284,7 @@ class PasskeyHandler {
1259
1284
  { type: 'public-key', alg: -7 }, // ES256
1260
1285
  { type: 'public-key', alg: -257 }, // RS256
1261
1286
  ],
1262
- timeout: challenge.timeout,
1287
+ timeout: challengeResponse.timeout,
1263
1288
  authenticatorSelection: {
1264
1289
  residentKey: 'preferred',
1265
1290
  userVerification: 'preferred',
@@ -1277,7 +1302,7 @@ class PasskeyHandler {
1277
1302
  };
1278
1303
  }
1279
1304
  const response = await this.api.completePasskeyRegister({
1280
- email: this.state.recipient,
1305
+ session: challengeResponse.session,
1281
1306
  credential,
1282
1307
  });
1283
1308
  // Update state - user now has a passkey
@@ -1300,13 +1325,13 @@ class PasskeyHandler {
1300
1325
  */
1301
1326
  async verify() {
1302
1327
  try {
1303
- const challenge = await this.api.startPasskeyVerify(this.state.recipient);
1328
+ const challengeResponse = await this.api.startPasskeyVerify(this.state.recipient);
1304
1329
  const publicKeyOptions = {
1305
- challenge: base64urlToArrayBuffer(challenge.challenge),
1306
- rpId: challenge.rpId,
1307
- timeout: challenge.timeout,
1330
+ challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1331
+ rpId: challengeResponse.rpId,
1332
+ timeout: challengeResponse.timeout,
1308
1333
  userVerification: 'preferred',
1309
- allowCredentials: challenge.allowCredentials?.map((cred) => ({
1334
+ allowCredentials: challengeResponse.allowCredentials?.map((cred) => ({
1310
1335
  id: base64urlToArrayBuffer(cred.id),
1311
1336
  type: cred.type,
1312
1337
  transports: ['internal', 'hybrid', 'usb', 'ble', 'nfc'],
@@ -1323,7 +1348,7 @@ class PasskeyHandler {
1323
1348
  };
1324
1349
  }
1325
1350
  const response = await this.api.completePasskeyVerify({
1326
- email: this.state.recipient,
1351
+ session: challengeResponse.session,
1327
1352
  credential,
1328
1353
  });
1329
1354
  return {
@@ -1731,7 +1756,7 @@ function getStyles(options) {
1731
1756
  ======================================== */
1732
1757
 
1733
1758
  .sv-body {
1734
- padding: 20px;
1759
+ padding: 24px;
1735
1760
  overflow-y: auto;
1736
1761
  flex: 1;
1737
1762
  }
@@ -2276,6 +2301,71 @@ function getStyles(options) {
2276
2301
  TOTP VIEW
2277
2302
  ======================================== */
2278
2303
 
2304
+ .sv-resend-row {
2305
+ display: flex;
2306
+ align-items: center;
2307
+ justify-content: space-between;
2308
+ margin-top: 16px;
2309
+ }
2310
+
2311
+ .sv-resend-btn {
2312
+ display: inline-flex;
2313
+ align-items: center;
2314
+ gap: 6px;
2315
+ color: ${tokens.textSecondary};
2316
+ font-size: 13px;
2317
+ font-weight: 500;
2318
+ text-decoration: none;
2319
+ cursor: pointer;
2320
+ background: none;
2321
+ border: none;
2322
+ padding: 0;
2323
+ transition: color 0.15s ease;
2324
+ }
2325
+
2326
+ .sv-resend-btn:hover:not(:disabled) {
2327
+ color: ${tokens.primary};
2328
+ }
2329
+
2330
+ .sv-resend-btn:disabled {
2331
+ opacity: 0.5;
2332
+ cursor: not-allowed;
2333
+ }
2334
+
2335
+ .sv-resend-btn svg {
2336
+ width: 14px;
2337
+ height: 14px;
2338
+ }
2339
+
2340
+ .sv-resend-timer {
2341
+ font-size: 13px;
2342
+ font-weight: 500;
2343
+ color: ${tokens.textMuted};
2344
+ min-width: 24px;
2345
+ text-align: right;
2346
+ }
2347
+
2348
+ .sv-alt-method-link {
2349
+ display: block;
2350
+ width: 100%;
2351
+ text-align: center;
2352
+ color: ${tokens.textSecondary};
2353
+ font-size: 13px;
2354
+ font-weight: 400;
2355
+ text-decoration: none;
2356
+ cursor: pointer;
2357
+ background: none;
2358
+ border: none;
2359
+ padding: 16px 0 0 0;
2360
+ margin-top: 8px;
2361
+ transition: color 0.15s ease;
2362
+ }
2363
+
2364
+ .sv-alt-method-link:hover {
2365
+ color: ${tokens.primary};
2366
+ text-decoration: underline;
2367
+ }
2368
+
2279
2369
  .sv-resend-container {
2280
2370
  text-align: center;
2281
2371
  margin-top: 16px;
@@ -2464,6 +2554,36 @@ function getStyles(options) {
2464
2554
  color: ${tokens.textSecondary};
2465
2555
  }
2466
2556
 
2557
+ /* SparkLink Expired State */
2558
+ .sv-sparklink-expired {
2559
+ flex-direction: column;
2560
+ gap: 12px;
2561
+ background: ${tokens.bgSubtle};
2562
+ border: 1px solid ${tokens.border};
2563
+ }
2564
+
2565
+ .sv-sparklink-expired-icon {
2566
+ display: flex;
2567
+ justify-content: center;
2568
+ }
2569
+
2570
+ .sv-sparklink-expired-icon svg {
2571
+ width: 48px;
2572
+ height: 48px;
2573
+ }
2574
+
2575
+ .sv-sparklink-expired-text {
2576
+ font-size: 14px;
2577
+ font-weight: 500;
2578
+ color: ${tokens.textSecondary};
2579
+ text-align: center;
2580
+ }
2581
+
2582
+ .sv-expired-icon {
2583
+ width: 48px;
2584
+ height: 48px;
2585
+ }
2586
+
2467
2587
  /* ========================================
2468
2588
  DIVIDER
2469
2589
  ======================================== */
@@ -2511,6 +2631,7 @@ function getStyles(options) {
2511
2631
  .sv-inline-container {
2512
2632
  display: flex;
2513
2633
  flex-direction: column;
2634
+ height: 100%;
2514
2635
  background: ${tokens.bg};
2515
2636
  color: ${tokens.textPrimary};
2516
2637
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -2526,7 +2647,11 @@ function getStyles(options) {
2526
2647
  }
2527
2648
 
2528
2649
  .sv-inline-body {
2650
+ display: flex;
2651
+ flex-direction: column;
2652
+ justify-content: center;
2529
2653
  min-height: 200px;
2654
+ flex: 1;
2530
2655
  }
2531
2656
 
2532
2657
  /* ========================================
@@ -2545,7 +2670,7 @@ function getStyles(options) {
2545
2670
  }
2546
2671
 
2547
2672
  .sv-body {
2548
- padding: 20px;
2673
+ padding: 24px;
2549
2674
  }
2550
2675
 
2551
2676
  .sv-totp-digit {
@@ -2632,6 +2757,27 @@ function createBackArrowIcon() {
2632
2757
  }));
2633
2758
  return svg;
2634
2759
  }
2760
+ /**
2761
+ * Resend/refresh icon - circular arrow
2762
+ */
2763
+ function createResendIcon() {
2764
+ const svg = createSvgElement('0 0 16 16');
2765
+ svg.appendChild(createPath('M13.5 8a5.5 5.5 0 11-1.21-3.45', {
2766
+ stroke: 'currentColor',
2767
+ 'stroke-width': '1.5',
2768
+ 'stroke-linecap': 'round',
2769
+ 'stroke-linejoin': 'round',
2770
+ fill: 'none',
2771
+ }));
2772
+ svg.appendChild(createPath('M13.5 2.5v2.5H11', {
2773
+ stroke: 'currentColor',
2774
+ 'stroke-width': '1.5',
2775
+ 'stroke-linecap': 'round',
2776
+ 'stroke-linejoin': 'round',
2777
+ fill: 'none',
2778
+ }));
2779
+ return svg;
2780
+ }
2635
2781
  /**
2636
2782
  * Close (X) icon
2637
2783
  */
@@ -2689,6 +2835,32 @@ function createErrorIcon() {
2689
2835
  svg.appendChild(group);
2690
2836
  return svg;
2691
2837
  }
2838
+ /**
2839
+ * Expired icon - grayscale triangle alert for expired states
2840
+ */
2841
+ function createExpiredIcon() {
2842
+ const svg = createSvgElement('0 0 48 48', 48, 48);
2843
+ svg.classList.add('sv-expired-icon');
2844
+ // Circle background - muted gray
2845
+ svg.appendChild(createCircle(24, 24, 22, {
2846
+ fill: '#F5F5F5',
2847
+ stroke: '#E5E5E5',
2848
+ 'stroke-width': '1',
2849
+ }));
2850
+ // Alert triangle - gray
2851
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2852
+ group.setAttribute('transform', 'translate(10, 12)');
2853
+ group.appendChild(createPath('M14 4L2 24h24L14 4z', { fill: '#737373' }));
2854
+ // Exclamation
2855
+ group.appendChild(createPath('M14 10v5', {
2856
+ stroke: '#FFFFFF',
2857
+ 'stroke-width': '2',
2858
+ 'stroke-linecap': 'round',
2859
+ }));
2860
+ group.appendChild(createCircle(14, 19, 1, { fill: '#FFFFFF' }));
2861
+ svg.appendChild(group);
2862
+ return svg;
2863
+ }
2692
2864
  /**
2693
2865
  * Method icons
2694
2866
  */
@@ -3528,6 +3700,7 @@ class TotpVerifyView {
3528
3700
  this.inputElements = [];
3529
3701
  this.submitButton = null;
3530
3702
  this.resendButton = null;
3703
+ this.timerDisplay = null;
3531
3704
  this.backLink = null;
3532
3705
  this.errorContainer = null;
3533
3706
  this.resendTimer = null;
@@ -3545,14 +3718,12 @@ class TotpVerifyView {
3545
3718
  }
3546
3719
  render() {
3547
3720
  const container = div();
3548
- this.backLink = this.createBackLink();
3549
3721
  const title = document.createElement('h3');
3550
3722
  title.className = 'sv-title';
3551
3723
  title.textContent = 'Enter verification code';
3552
- const methodText = this.props.method === 'email' ? 'email' : 'phone';
3553
3724
  const subtitle = document.createElement('p');
3554
3725
  subtitle.className = 'sv-subtitle';
3555
- subtitle.innerHTML = `We sent a 6-digit code to your ${methodText}<br><strong>${escapeHtml(this.props.email)}</strong>`;
3726
+ subtitle.innerHTML = `Enter the 6 digit code that has been sent to <strong>${escapeHtml(this.props.email)}</strong> to log in.`;
3556
3727
  this.errorContainer = div();
3557
3728
  if (this.props.error) {
3558
3729
  this.errorContainer.appendChild(errorMessage(this.props.error));
@@ -3570,35 +3741,32 @@ class TotpVerifyView {
3570
3741
  this.startBackoff(this.props.backoffExpires);
3571
3742
  }
3572
3743
  }
3573
- const resendContainer = div('sv-resend-container');
3744
+ // Resend row: icon + text on left, timer on right
3745
+ const resendContainer = div('sv-resend-row');
3574
3746
  this.resendButton = document.createElement('button');
3575
- this.resendButton.className = 'sv-back-link';
3576
- this.resendButton.textContent = 'Resend code';
3747
+ this.resendButton.className = 'sv-resend-btn';
3748
+ this.resendButton.appendChild(createResendIcon());
3749
+ this.resendButton.appendChild(document.createTextNode('Resend code'));
3577
3750
  this.resendButton.addEventListener('click', this.boundHandleResend);
3751
+ this.timerDisplay = document.createElement('span');
3752
+ this.timerDisplay.className = 'sv-resend-timer';
3578
3753
  resendContainer.appendChild(this.resendButton);
3579
- // Use flex column layout so we can use CSS order for tab order control
3580
- container.style.display = 'flex';
3581
- container.style.flexDirection = 'column';
3582
- // Append in TAB ORDER (not visual order) - back link last for tab navigation
3754
+ resendContainer.appendChild(this.timerDisplay);
3755
+ // "Log in another way" link at bottom
3756
+ this.backLink = document.createElement('button');
3757
+ this.backLink.className = 'sv-alt-method-link';
3758
+ this.backLink.textContent = 'Log in another way';
3759
+ this.backLink.addEventListener('click', this.boundHandleBack);
3583
3760
  container.appendChild(title);
3584
3761
  container.appendChild(subtitle);
3585
3762
  container.appendChild(this.errorContainer);
3586
3763
  container.appendChild(inputGroup);
3587
3764
  container.appendChild(this.submitButton);
3588
3765
  container.appendChild(resendContainer);
3589
- container.appendChild(this.backLink); // Last in DOM = last in tab order
3766
+ container.appendChild(this.backLink);
3590
3767
  this.focusTimeoutId = setTimeout(() => this.inputElements[0]?.focus(), 50);
3591
3768
  return container;
3592
3769
  }
3593
- createBackLink() {
3594
- const link = document.createElement('button');
3595
- link.className = 'sv-back-link';
3596
- link.style.order = '-1'; // Visually keep at top with flexbox (DOM order controls tab order)
3597
- link.appendChild(createBackArrowIcon());
3598
- link.appendChild(document.createTextNode(' Choose another method'));
3599
- link.addEventListener('click', this.boundHandleBack);
3600
- return link;
3601
- }
3602
3770
  createInputGroup() {
3603
3771
  const group = div('sv-totp-input-group');
3604
3772
  for (let i = 0; i < CODE_LENGTH; i++) {
@@ -3773,15 +3941,15 @@ class TotpVerifyView {
3773
3941
  this.resendTimer = new CooldownTimer({
3774
3942
  duration: 30,
3775
3943
  onTick: (secondsRemaining) => {
3776
- if (!this.resendButton)
3944
+ if (!this.resendButton || !this.timerDisplay)
3777
3945
  return;
3778
- this.resendButton.textContent = `Resend code (${secondsRemaining}s)`;
3946
+ this.timerDisplay.textContent = `${secondsRemaining}s`;
3779
3947
  this.resendButton.disabled = true;
3780
3948
  },
3781
3949
  onComplete: () => {
3782
- if (!this.resendButton)
3950
+ if (!this.resendButton || !this.timerDisplay)
3783
3951
  return;
3784
- this.resendButton.textContent = 'Resend code';
3952
+ this.timerDisplay.textContent = '';
3785
3953
  this.resendButton.disabled = false;
3786
3954
  },
3787
3955
  });
@@ -4021,6 +4189,8 @@ class SparkLinkWaitingView {
4021
4189
  this.expirationTimer = null;
4022
4190
  this.resendTimer = null;
4023
4191
  this.countdownElement = null;
4192
+ this.countdownSection = null;
4193
+ this.waitingSection = null;
4024
4194
  this.resendButton = null;
4025
4195
  this.backLink = null;
4026
4196
  this.fallbackButton = null;
@@ -4039,7 +4209,7 @@ class SparkLinkWaitingView {
4039
4209
  subtitle.className = 'sv-subtitle';
4040
4210
  subtitle.innerHTML = `We sent a secure link to<br><strong>${escapeHtml(this.props.email)}</strong>`;
4041
4211
  // Spinner with message
4042
- const waitingSection = div('sv-sparklink-waiting');
4212
+ this.waitingSection = div('sv-sparklink-waiting');
4043
4213
  const spinner = document.createElement('div');
4044
4214
  spinner.className = 'sv-spinner sv-spinner-small';
4045
4215
  spinner.setAttribute('role', 'status');
@@ -4047,17 +4217,17 @@ class SparkLinkWaitingView {
4047
4217
  const waitingText = document.createElement('span');
4048
4218
  waitingText.className = 'sv-sparklink-waiting-text';
4049
4219
  waitingText.textContent = 'Waiting for you to click the link...';
4050
- waitingSection.appendChild(spinner);
4051
- waitingSection.appendChild(waitingText);
4220
+ this.waitingSection.appendChild(spinner);
4221
+ this.waitingSection.appendChild(waitingText);
4052
4222
  // Countdown timer
4053
- const countdownSection = div('sv-sparklink-countdown');
4223
+ this.countdownSection = div('sv-sparklink-countdown');
4054
4224
  const countdownLabel = document.createElement('span');
4055
4225
  countdownLabel.textContent = 'Link expires in ';
4056
4226
  this.countdownElement = document.createElement('span');
4057
4227
  this.countdownElement.className = 'sv-sparklink-countdown-time';
4058
4228
  this.startExpirationTimer();
4059
- countdownSection.appendChild(countdownLabel);
4060
- countdownSection.appendChild(this.countdownElement);
4229
+ this.countdownSection.appendChild(countdownLabel);
4230
+ this.countdownSection.appendChild(this.countdownElement);
4061
4231
  // Resend button
4062
4232
  const resendContainer = div('sv-resend-container');
4063
4233
  this.resendButton = document.createElement('button');
@@ -4075,8 +4245,8 @@ class SparkLinkWaitingView {
4075
4245
  container.appendChild(this.backLink);
4076
4246
  container.appendChild(title);
4077
4247
  container.appendChild(subtitle);
4078
- container.appendChild(waitingSection);
4079
- container.appendChild(countdownSection);
4248
+ container.appendChild(this.waitingSection);
4249
+ container.appendChild(this.countdownSection);
4080
4250
  container.appendChild(resendContainer);
4081
4251
  container.appendChild(fallbackContainer);
4082
4252
  return container;
@@ -4118,13 +4288,36 @@ class SparkLinkWaitingView {
4118
4288
  this.countdownElement.textContent = this.formatTime(secondsRemaining);
4119
4289
  },
4120
4290
  onExpired: () => {
4121
- if (!this.countdownElement)
4122
- return;
4123
- this.countdownElement.textContent = 'Expired';
4291
+ this.showExpiredState();
4124
4292
  },
4125
4293
  });
4126
4294
  this.expirationTimer.start();
4127
4295
  }
4296
+ /**
4297
+ * Switch to expired state - static icon, no animation, helpful message
4298
+ */
4299
+ showExpiredState() {
4300
+ // Notify renderer to stop polling
4301
+ this.props.onExpired();
4302
+ // Update waiting section: replace spinner with expired icon
4303
+ if (this.waitingSection) {
4304
+ this.waitingSection.innerHTML = '';
4305
+ this.waitingSection.classList.add('sv-sparklink-expired');
4306
+ // Add expired icon
4307
+ const iconContainer = div('sv-sparklink-expired-icon');
4308
+ iconContainer.appendChild(createExpiredIcon());
4309
+ this.waitingSection.appendChild(iconContainer);
4310
+ // Add expired message
4311
+ const expiredText = document.createElement('span');
4312
+ expiredText.className = 'sv-sparklink-expired-text';
4313
+ expiredText.textContent = 'This link has expired';
4314
+ this.waitingSection.appendChild(expiredText);
4315
+ }
4316
+ // Hide countdown section
4317
+ if (this.countdownSection) {
4318
+ this.countdownSection.style.display = 'none';
4319
+ }
4320
+ }
4128
4321
  handleResend() {
4129
4322
  if (this.resendTimer?.isRunning() || !this.resendButton)
4130
4323
  return;
@@ -4251,8 +4444,9 @@ class IdentityRenderer {
4251
4444
  // View management
4252
4445
  this.currentView = null;
4253
4446
  this.focusTimeoutId = null;
4254
- // SparkLink polling
4447
+ // SparkLink polling and postMessage listener
4255
4448
  this.pollingInterval = null;
4449
+ this.messageListener = null;
4256
4450
  this.container = container;
4257
4451
  this.api = api;
4258
4452
  this.options = options;
@@ -4374,7 +4568,11 @@ class IdentityRenderer {
4374
4568
  this.currentView = view;
4375
4569
  clearChildren(body);
4376
4570
  body.appendChild(view.render());
4377
- const focusable = body.querySelector('input:not([disabled]), button:not([disabled])');
4571
+ // Auto-focus: prefer inputs first, then primary/method buttons (skip back links)
4572
+ const focusable = body.querySelector('input:not([disabled]), ' +
4573
+ 'button.sv-btn-primary:not([disabled]), ' +
4574
+ 'button.sv-btn-email-primary:not([disabled]), ' +
4575
+ 'button.sv-btn-method:not([disabled])');
4378
4576
  if (focusable) {
4379
4577
  // Clear any previous focus timeout to avoid stacking
4380
4578
  if (this.focusTimeoutId !== null) {
@@ -4447,6 +4645,7 @@ class IdentityRenderer {
4447
4645
  onResend: () => this.handleSparkLinkResend(),
4448
4646
  onFallback: () => this.handleSparkLinkFallback(),
4449
4647
  onBack: () => this.showMethodSelect(),
4648
+ onExpired: () => this.stopSparkLinkPolling(),
4450
4649
  });
4451
4650
  case 'oauth-pending':
4452
4651
  return new LoadingView({ message: `Connecting to ${state.provider}...` });
@@ -4789,41 +4988,73 @@ class IdentityRenderer {
4789
4988
  this.callbacks.onSuccess(pendingResult);
4790
4989
  }
4791
4990
  /**
4792
- * Start polling for SparkLink verification status.
4991
+ * Start listening for SparkLink verification completion.
4992
+ * Uses postMessage as primary mechanism (instant notification from ceremony page),
4993
+ * with polling as fallback for edge cases where postMessage might fail.
4793
4994
  */
4794
4995
  startSparkLinkPolling(sparkId) {
4795
4996
  this.stopSparkLinkPolling();
4997
+ // Primary: Listen for postMessage from ceremony page
4998
+ // This is faster and more reliable than polling
4999
+ this.messageListener = (event) => {
5000
+ // Validate message structure and type
5001
+ if (!event.data || typeof event.data !== 'object')
5002
+ return;
5003
+ if (event.data.type !== 'sparklink_verified')
5004
+ return;
5005
+ const { token, identity, identityType } = event.data;
5006
+ // Validate required fields
5007
+ if (!token || !identity)
5008
+ return;
5009
+ this.handleSparkLinkVerified({
5010
+ token,
5011
+ identity,
5012
+ identityType: identityType || 'email',
5013
+ });
5014
+ };
5015
+ window.addEventListener('message', this.messageListener);
5016
+ // Fallback: Poll status endpoint every 2 seconds
5017
+ // This catches cases where postMessage might not work (popup blockers, etc)
4796
5018
  this.pollingInterval = setInterval(async () => {
4797
5019
  const status = await this.sparkLinkHandler.checkStatus(sparkId);
4798
5020
  if (status.verified && status.token && status.identity) {
4799
- this.stopSparkLinkPolling();
4800
- const result = {
5021
+ this.handleSparkLinkVerified({
4801
5022
  token: status.token,
4802
5023
  identity: status.identity,
4803
5024
  identityType: status.identityType || 'email',
4804
- };
4805
- // Check if we should prompt for passkey registration
4806
- if (await this.shouldShowPasskeyPrompt()) {
4807
- this.setState({
4808
- view: 'passkey-prompt',
4809
- email: this.recipient,
4810
- pendingResult: result,
4811
- });
4812
- return;
4813
- }
4814
- this.close();
4815
- this.callbacks.onSuccess(result);
5025
+ });
4816
5026
  }
4817
5027
  }, 2000);
4818
5028
  }
4819
5029
  /**
4820
- * Stop SparkLink polling.
5030
+ * Handle successful SparkLink verification (from either postMessage or polling).
5031
+ */
5032
+ async handleSparkLinkVerified(result) {
5033
+ this.stopSparkLinkPolling();
5034
+ // Check if we should prompt for passkey registration
5035
+ if (await this.shouldShowPasskeyPrompt()) {
5036
+ this.setState({
5037
+ view: 'passkey-prompt',
5038
+ email: this.recipient,
5039
+ pendingResult: result,
5040
+ });
5041
+ return;
5042
+ }
5043
+ this.close();
5044
+ this.callbacks.onSuccess(result);
5045
+ }
5046
+ /**
5047
+ * Stop SparkLink polling and message listener.
4821
5048
  */
4822
5049
  stopSparkLinkPolling() {
4823
5050
  if (this.pollingInterval) {
4824
5051
  clearInterval(this.pollingInterval);
4825
5052
  this.pollingInterval = null;
4826
5053
  }
5054
+ if (this.messageListener) {
5055
+ window.removeEventListener('message', this.messageListener);
5056
+ this.messageListener = null;
5057
+ }
4827
5058
  }
4828
5059
  /**
4829
5060
  * Handle resending SparkLink.