@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.
@@ -730,7 +730,18 @@ class IdentityApi {
730
730
  * Start passkey registration
731
731
  */
732
732
  async startPasskeyRegister(email) {
733
- return this.request('POST', '/passkey/register', { email });
733
+ // Backend returns { options: PublicKeyCredentialCreationOptions, session: {...} }
734
+ // Extract and flatten to match PasskeyChallengeResponse
735
+ const response = await this.request('POST', '/passkey/register', { email });
736
+ return {
737
+ challenge: response.options.challenge,
738
+ rpId: response.options.rp.id,
739
+ rpName: response.options.rp.name,
740
+ userId: response.options.user.id,
741
+ userName: response.options.user.name,
742
+ timeout: response.options.timeout,
743
+ session: response.session,
744
+ };
734
745
  }
735
746
  /**
736
747
  * Complete passkey registration
@@ -738,7 +749,7 @@ class IdentityApi {
738
749
  async completePasskeyRegister(params) {
739
750
  const attestation = params.credential.response;
740
751
  return this.request('POST', '/passkey/register/complete', {
741
- email: params.email,
752
+ session: params.session,
742
753
  credential: {
743
754
  id: params.credential.id,
744
755
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -754,7 +765,17 @@ class IdentityApi {
754
765
  * Start passkey verification
755
766
  */
756
767
  async startPasskeyVerify(email) {
757
- return this.request('POST', '/passkey/verify', { email });
768
+ // Backend returns { options: PublicKeyCredentialRequestOptions, session: {...} }
769
+ // Extract and flatten to match PasskeyChallengeResponse
770
+ const response = await this.request('POST', '/passkey/verify', { email });
771
+ return {
772
+ challenge: response.options.challenge,
773
+ rpId: response.options.rpId,
774
+ rpName: 'SparkVault Identity', // Not returned by verify endpoint
775
+ timeout: response.options.timeout,
776
+ allowCredentials: response.options.allowCredentials,
777
+ session: response.session,
778
+ };
758
779
  }
759
780
  /**
760
781
  * Complete passkey verification
@@ -762,7 +783,7 @@ class IdentityApi {
762
783
  async completePasskeyVerify(params) {
763
784
  const assertion = params.credential.response;
764
785
  return this.request('POST', '/passkey/verify/complete', {
765
- email: params.email,
786
+ session: params.session,
766
787
  credential: {
767
788
  id: params.credential.id,
768
789
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -799,12 +820,16 @@ class IdentityApi {
799
820
  return `${this.baseUrl}/saml/${provider}?${params}`;
800
821
  }
801
822
  /**
802
- * Send SparkLink email for identity verification
823
+ * Send SparkLink email for identity verification.
824
+ * Includes openerOrigin for postMessage-based completion notification.
803
825
  */
804
826
  async sendSparkLink(email) {
805
827
  return this.request('POST', '/sparklink/send', {
806
828
  email,
807
829
  type: 'verify_identity',
830
+ // Send opener origin for postMessage on verification completion
831
+ // This allows the ceremony page to notify the SDK directly instead of polling
832
+ openerOrigin: typeof window !== 'undefined' ? window.location.origin : undefined,
808
833
  });
809
834
  }
810
835
  /**
@@ -1247,15 +1272,15 @@ class PasskeyHandler {
1247
1272
  */
1248
1273
  async register() {
1249
1274
  try {
1250
- const challenge = await this.api.startPasskeyRegister(this.state.recipient);
1275
+ const challengeResponse = await this.api.startPasskeyRegister(this.state.recipient);
1251
1276
  const publicKeyOptions = {
1252
- challenge: base64urlToArrayBuffer(challenge.challenge),
1277
+ challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1253
1278
  rp: {
1254
- id: challenge.rpId,
1255
- name: challenge.rpName,
1279
+ id: challengeResponse.rpId,
1280
+ name: challengeResponse.rpName,
1256
1281
  },
1257
1282
  user: {
1258
- id: base64urlToArrayBuffer(challenge.userId ?? this.state.recipient),
1283
+ id: base64urlToArrayBuffer(challengeResponse.userId ?? this.state.recipient),
1259
1284
  name: this.state.recipient,
1260
1285
  displayName: this.state.recipient,
1261
1286
  },
@@ -1263,7 +1288,7 @@ class PasskeyHandler {
1263
1288
  { type: 'public-key', alg: -7 }, // ES256
1264
1289
  { type: 'public-key', alg: -257 }, // RS256
1265
1290
  ],
1266
- timeout: challenge.timeout,
1291
+ timeout: challengeResponse.timeout,
1267
1292
  authenticatorSelection: {
1268
1293
  residentKey: 'preferred',
1269
1294
  userVerification: 'preferred',
@@ -1281,7 +1306,7 @@ class PasskeyHandler {
1281
1306
  };
1282
1307
  }
1283
1308
  const response = await this.api.completePasskeyRegister({
1284
- email: this.state.recipient,
1309
+ session: challengeResponse.session,
1285
1310
  credential,
1286
1311
  });
1287
1312
  // Update state - user now has a passkey
@@ -1304,13 +1329,13 @@ class PasskeyHandler {
1304
1329
  */
1305
1330
  async verify() {
1306
1331
  try {
1307
- const challenge = await this.api.startPasskeyVerify(this.state.recipient);
1332
+ const challengeResponse = await this.api.startPasskeyVerify(this.state.recipient);
1308
1333
  const publicKeyOptions = {
1309
- challenge: base64urlToArrayBuffer(challenge.challenge),
1310
- rpId: challenge.rpId,
1311
- timeout: challenge.timeout,
1334
+ challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1335
+ rpId: challengeResponse.rpId,
1336
+ timeout: challengeResponse.timeout,
1312
1337
  userVerification: 'preferred',
1313
- allowCredentials: challenge.allowCredentials?.map((cred) => ({
1338
+ allowCredentials: challengeResponse.allowCredentials?.map((cred) => ({
1314
1339
  id: base64urlToArrayBuffer(cred.id),
1315
1340
  type: cred.type,
1316
1341
  transports: ['internal', 'hybrid', 'usb', 'ble', 'nfc'],
@@ -1327,7 +1352,7 @@ class PasskeyHandler {
1327
1352
  };
1328
1353
  }
1329
1354
  const response = await this.api.completePasskeyVerify({
1330
- email: this.state.recipient,
1355
+ session: challengeResponse.session,
1331
1356
  credential,
1332
1357
  });
1333
1358
  return {
@@ -1735,7 +1760,7 @@ function getStyles(options) {
1735
1760
  ======================================== */
1736
1761
 
1737
1762
  .sv-body {
1738
- padding: 20px;
1763
+ padding: 24px;
1739
1764
  overflow-y: auto;
1740
1765
  flex: 1;
1741
1766
  }
@@ -2280,6 +2305,71 @@ function getStyles(options) {
2280
2305
  TOTP VIEW
2281
2306
  ======================================== */
2282
2307
 
2308
+ .sv-resend-row {
2309
+ display: flex;
2310
+ align-items: center;
2311
+ justify-content: space-between;
2312
+ margin-top: 16px;
2313
+ }
2314
+
2315
+ .sv-resend-btn {
2316
+ display: inline-flex;
2317
+ align-items: center;
2318
+ gap: 6px;
2319
+ color: ${tokens.textSecondary};
2320
+ font-size: 13px;
2321
+ font-weight: 500;
2322
+ text-decoration: none;
2323
+ cursor: pointer;
2324
+ background: none;
2325
+ border: none;
2326
+ padding: 0;
2327
+ transition: color 0.15s ease;
2328
+ }
2329
+
2330
+ .sv-resend-btn:hover:not(:disabled) {
2331
+ color: ${tokens.primary};
2332
+ }
2333
+
2334
+ .sv-resend-btn:disabled {
2335
+ opacity: 0.5;
2336
+ cursor: not-allowed;
2337
+ }
2338
+
2339
+ .sv-resend-btn svg {
2340
+ width: 14px;
2341
+ height: 14px;
2342
+ }
2343
+
2344
+ .sv-resend-timer {
2345
+ font-size: 13px;
2346
+ font-weight: 500;
2347
+ color: ${tokens.textMuted};
2348
+ min-width: 24px;
2349
+ text-align: right;
2350
+ }
2351
+
2352
+ .sv-alt-method-link {
2353
+ display: block;
2354
+ width: 100%;
2355
+ text-align: center;
2356
+ color: ${tokens.textSecondary};
2357
+ font-size: 13px;
2358
+ font-weight: 400;
2359
+ text-decoration: none;
2360
+ cursor: pointer;
2361
+ background: none;
2362
+ border: none;
2363
+ padding: 16px 0 0 0;
2364
+ margin-top: 8px;
2365
+ transition: color 0.15s ease;
2366
+ }
2367
+
2368
+ .sv-alt-method-link:hover {
2369
+ color: ${tokens.primary};
2370
+ text-decoration: underline;
2371
+ }
2372
+
2283
2373
  .sv-resend-container {
2284
2374
  text-align: center;
2285
2375
  margin-top: 16px;
@@ -2468,6 +2558,36 @@ function getStyles(options) {
2468
2558
  color: ${tokens.textSecondary};
2469
2559
  }
2470
2560
 
2561
+ /* SparkLink Expired State */
2562
+ .sv-sparklink-expired {
2563
+ flex-direction: column;
2564
+ gap: 12px;
2565
+ background: ${tokens.bgSubtle};
2566
+ border: 1px solid ${tokens.border};
2567
+ }
2568
+
2569
+ .sv-sparklink-expired-icon {
2570
+ display: flex;
2571
+ justify-content: center;
2572
+ }
2573
+
2574
+ .sv-sparklink-expired-icon svg {
2575
+ width: 48px;
2576
+ height: 48px;
2577
+ }
2578
+
2579
+ .sv-sparklink-expired-text {
2580
+ font-size: 14px;
2581
+ font-weight: 500;
2582
+ color: ${tokens.textSecondary};
2583
+ text-align: center;
2584
+ }
2585
+
2586
+ .sv-expired-icon {
2587
+ width: 48px;
2588
+ height: 48px;
2589
+ }
2590
+
2471
2591
  /* ========================================
2472
2592
  DIVIDER
2473
2593
  ======================================== */
@@ -2515,6 +2635,7 @@ function getStyles(options) {
2515
2635
  .sv-inline-container {
2516
2636
  display: flex;
2517
2637
  flex-direction: column;
2638
+ height: 100%;
2518
2639
  background: ${tokens.bg};
2519
2640
  color: ${tokens.textPrimary};
2520
2641
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -2530,7 +2651,11 @@ function getStyles(options) {
2530
2651
  }
2531
2652
 
2532
2653
  .sv-inline-body {
2654
+ display: flex;
2655
+ flex-direction: column;
2656
+ justify-content: center;
2533
2657
  min-height: 200px;
2658
+ flex: 1;
2534
2659
  }
2535
2660
 
2536
2661
  /* ========================================
@@ -2549,7 +2674,7 @@ function getStyles(options) {
2549
2674
  }
2550
2675
 
2551
2676
  .sv-body {
2552
- padding: 20px;
2677
+ padding: 24px;
2553
2678
  }
2554
2679
 
2555
2680
  .sv-totp-digit {
@@ -2636,6 +2761,27 @@ function createBackArrowIcon() {
2636
2761
  }));
2637
2762
  return svg;
2638
2763
  }
2764
+ /**
2765
+ * Resend/refresh icon - circular arrow
2766
+ */
2767
+ function createResendIcon() {
2768
+ const svg = createSvgElement('0 0 16 16');
2769
+ svg.appendChild(createPath('M13.5 8a5.5 5.5 0 11-1.21-3.45', {
2770
+ stroke: 'currentColor',
2771
+ 'stroke-width': '1.5',
2772
+ 'stroke-linecap': 'round',
2773
+ 'stroke-linejoin': 'round',
2774
+ fill: 'none',
2775
+ }));
2776
+ svg.appendChild(createPath('M13.5 2.5v2.5H11', {
2777
+ stroke: 'currentColor',
2778
+ 'stroke-width': '1.5',
2779
+ 'stroke-linecap': 'round',
2780
+ 'stroke-linejoin': 'round',
2781
+ fill: 'none',
2782
+ }));
2783
+ return svg;
2784
+ }
2639
2785
  /**
2640
2786
  * Close (X) icon
2641
2787
  */
@@ -2693,6 +2839,32 @@ function createErrorIcon() {
2693
2839
  svg.appendChild(group);
2694
2840
  return svg;
2695
2841
  }
2842
+ /**
2843
+ * Expired icon - grayscale triangle alert for expired states
2844
+ */
2845
+ function createExpiredIcon() {
2846
+ const svg = createSvgElement('0 0 48 48', 48, 48);
2847
+ svg.classList.add('sv-expired-icon');
2848
+ // Circle background - muted gray
2849
+ svg.appendChild(createCircle(24, 24, 22, {
2850
+ fill: '#F5F5F5',
2851
+ stroke: '#E5E5E5',
2852
+ 'stroke-width': '1',
2853
+ }));
2854
+ // Alert triangle - gray
2855
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2856
+ group.setAttribute('transform', 'translate(10, 12)');
2857
+ group.appendChild(createPath('M14 4L2 24h24L14 4z', { fill: '#737373' }));
2858
+ // Exclamation
2859
+ group.appendChild(createPath('M14 10v5', {
2860
+ stroke: '#FFFFFF',
2861
+ 'stroke-width': '2',
2862
+ 'stroke-linecap': 'round',
2863
+ }));
2864
+ group.appendChild(createCircle(14, 19, 1, { fill: '#FFFFFF' }));
2865
+ svg.appendChild(group);
2866
+ return svg;
2867
+ }
2696
2868
  /**
2697
2869
  * Method icons
2698
2870
  */
@@ -3532,6 +3704,7 @@ class TotpVerifyView {
3532
3704
  this.inputElements = [];
3533
3705
  this.submitButton = null;
3534
3706
  this.resendButton = null;
3707
+ this.timerDisplay = null;
3535
3708
  this.backLink = null;
3536
3709
  this.errorContainer = null;
3537
3710
  this.resendTimer = null;
@@ -3549,14 +3722,12 @@ class TotpVerifyView {
3549
3722
  }
3550
3723
  render() {
3551
3724
  const container = div();
3552
- this.backLink = this.createBackLink();
3553
3725
  const title = document.createElement('h3');
3554
3726
  title.className = 'sv-title';
3555
3727
  title.textContent = 'Enter verification code';
3556
- const methodText = this.props.method === 'email' ? 'email' : 'phone';
3557
3728
  const subtitle = document.createElement('p');
3558
3729
  subtitle.className = 'sv-subtitle';
3559
- subtitle.innerHTML = `We sent a 6-digit code to your ${methodText}<br><strong>${escapeHtml(this.props.email)}</strong>`;
3730
+ subtitle.innerHTML = `Enter the 6 digit code that has been sent to <strong>${escapeHtml(this.props.email)}</strong> to log in.`;
3560
3731
  this.errorContainer = div();
3561
3732
  if (this.props.error) {
3562
3733
  this.errorContainer.appendChild(errorMessage(this.props.error));
@@ -3574,35 +3745,32 @@ class TotpVerifyView {
3574
3745
  this.startBackoff(this.props.backoffExpires);
3575
3746
  }
3576
3747
  }
3577
- const resendContainer = div('sv-resend-container');
3748
+ // Resend row: icon + text on left, timer on right
3749
+ const resendContainer = div('sv-resend-row');
3578
3750
  this.resendButton = document.createElement('button');
3579
- this.resendButton.className = 'sv-back-link';
3580
- this.resendButton.textContent = 'Resend code';
3751
+ this.resendButton.className = 'sv-resend-btn';
3752
+ this.resendButton.appendChild(createResendIcon());
3753
+ this.resendButton.appendChild(document.createTextNode('Resend code'));
3581
3754
  this.resendButton.addEventListener('click', this.boundHandleResend);
3755
+ this.timerDisplay = document.createElement('span');
3756
+ this.timerDisplay.className = 'sv-resend-timer';
3582
3757
  resendContainer.appendChild(this.resendButton);
3583
- // Use flex column layout so we can use CSS order for tab order control
3584
- container.style.display = 'flex';
3585
- container.style.flexDirection = 'column';
3586
- // Append in TAB ORDER (not visual order) - back link last for tab navigation
3758
+ resendContainer.appendChild(this.timerDisplay);
3759
+ // "Log in another way" link at bottom
3760
+ this.backLink = document.createElement('button');
3761
+ this.backLink.className = 'sv-alt-method-link';
3762
+ this.backLink.textContent = 'Log in another way';
3763
+ this.backLink.addEventListener('click', this.boundHandleBack);
3587
3764
  container.appendChild(title);
3588
3765
  container.appendChild(subtitle);
3589
3766
  container.appendChild(this.errorContainer);
3590
3767
  container.appendChild(inputGroup);
3591
3768
  container.appendChild(this.submitButton);
3592
3769
  container.appendChild(resendContainer);
3593
- container.appendChild(this.backLink); // Last in DOM = last in tab order
3770
+ container.appendChild(this.backLink);
3594
3771
  this.focusTimeoutId = setTimeout(() => this.inputElements[0]?.focus(), 50);
3595
3772
  return container;
3596
3773
  }
3597
- createBackLink() {
3598
- const link = document.createElement('button');
3599
- link.className = 'sv-back-link';
3600
- link.style.order = '-1'; // Visually keep at top with flexbox (DOM order controls tab order)
3601
- link.appendChild(createBackArrowIcon());
3602
- link.appendChild(document.createTextNode(' Choose another method'));
3603
- link.addEventListener('click', this.boundHandleBack);
3604
- return link;
3605
- }
3606
3774
  createInputGroup() {
3607
3775
  const group = div('sv-totp-input-group');
3608
3776
  for (let i = 0; i < CODE_LENGTH; i++) {
@@ -3777,15 +3945,15 @@ class TotpVerifyView {
3777
3945
  this.resendTimer = new CooldownTimer({
3778
3946
  duration: 30,
3779
3947
  onTick: (secondsRemaining) => {
3780
- if (!this.resendButton)
3948
+ if (!this.resendButton || !this.timerDisplay)
3781
3949
  return;
3782
- this.resendButton.textContent = `Resend code (${secondsRemaining}s)`;
3950
+ this.timerDisplay.textContent = `${secondsRemaining}s`;
3783
3951
  this.resendButton.disabled = true;
3784
3952
  },
3785
3953
  onComplete: () => {
3786
- if (!this.resendButton)
3954
+ if (!this.resendButton || !this.timerDisplay)
3787
3955
  return;
3788
- this.resendButton.textContent = 'Resend code';
3956
+ this.timerDisplay.textContent = '';
3789
3957
  this.resendButton.disabled = false;
3790
3958
  },
3791
3959
  });
@@ -4025,6 +4193,8 @@ class SparkLinkWaitingView {
4025
4193
  this.expirationTimer = null;
4026
4194
  this.resendTimer = null;
4027
4195
  this.countdownElement = null;
4196
+ this.countdownSection = null;
4197
+ this.waitingSection = null;
4028
4198
  this.resendButton = null;
4029
4199
  this.backLink = null;
4030
4200
  this.fallbackButton = null;
@@ -4043,7 +4213,7 @@ class SparkLinkWaitingView {
4043
4213
  subtitle.className = 'sv-subtitle';
4044
4214
  subtitle.innerHTML = `We sent a secure link to<br><strong>${escapeHtml(this.props.email)}</strong>`;
4045
4215
  // Spinner with message
4046
- const waitingSection = div('sv-sparklink-waiting');
4216
+ this.waitingSection = div('sv-sparklink-waiting');
4047
4217
  const spinner = document.createElement('div');
4048
4218
  spinner.className = 'sv-spinner sv-spinner-small';
4049
4219
  spinner.setAttribute('role', 'status');
@@ -4051,17 +4221,17 @@ class SparkLinkWaitingView {
4051
4221
  const waitingText = document.createElement('span');
4052
4222
  waitingText.className = 'sv-sparklink-waiting-text';
4053
4223
  waitingText.textContent = 'Waiting for you to click the link...';
4054
- waitingSection.appendChild(spinner);
4055
- waitingSection.appendChild(waitingText);
4224
+ this.waitingSection.appendChild(spinner);
4225
+ this.waitingSection.appendChild(waitingText);
4056
4226
  // Countdown timer
4057
- const countdownSection = div('sv-sparklink-countdown');
4227
+ this.countdownSection = div('sv-sparklink-countdown');
4058
4228
  const countdownLabel = document.createElement('span');
4059
4229
  countdownLabel.textContent = 'Link expires in ';
4060
4230
  this.countdownElement = document.createElement('span');
4061
4231
  this.countdownElement.className = 'sv-sparklink-countdown-time';
4062
4232
  this.startExpirationTimer();
4063
- countdownSection.appendChild(countdownLabel);
4064
- countdownSection.appendChild(this.countdownElement);
4233
+ this.countdownSection.appendChild(countdownLabel);
4234
+ this.countdownSection.appendChild(this.countdownElement);
4065
4235
  // Resend button
4066
4236
  const resendContainer = div('sv-resend-container');
4067
4237
  this.resendButton = document.createElement('button');
@@ -4079,8 +4249,8 @@ class SparkLinkWaitingView {
4079
4249
  container.appendChild(this.backLink);
4080
4250
  container.appendChild(title);
4081
4251
  container.appendChild(subtitle);
4082
- container.appendChild(waitingSection);
4083
- container.appendChild(countdownSection);
4252
+ container.appendChild(this.waitingSection);
4253
+ container.appendChild(this.countdownSection);
4084
4254
  container.appendChild(resendContainer);
4085
4255
  container.appendChild(fallbackContainer);
4086
4256
  return container;
@@ -4122,13 +4292,36 @@ class SparkLinkWaitingView {
4122
4292
  this.countdownElement.textContent = this.formatTime(secondsRemaining);
4123
4293
  },
4124
4294
  onExpired: () => {
4125
- if (!this.countdownElement)
4126
- return;
4127
- this.countdownElement.textContent = 'Expired';
4295
+ this.showExpiredState();
4128
4296
  },
4129
4297
  });
4130
4298
  this.expirationTimer.start();
4131
4299
  }
4300
+ /**
4301
+ * Switch to expired state - static icon, no animation, helpful message
4302
+ */
4303
+ showExpiredState() {
4304
+ // Notify renderer to stop polling
4305
+ this.props.onExpired();
4306
+ // Update waiting section: replace spinner with expired icon
4307
+ if (this.waitingSection) {
4308
+ this.waitingSection.innerHTML = '';
4309
+ this.waitingSection.classList.add('sv-sparklink-expired');
4310
+ // Add expired icon
4311
+ const iconContainer = div('sv-sparklink-expired-icon');
4312
+ iconContainer.appendChild(createExpiredIcon());
4313
+ this.waitingSection.appendChild(iconContainer);
4314
+ // Add expired message
4315
+ const expiredText = document.createElement('span');
4316
+ expiredText.className = 'sv-sparklink-expired-text';
4317
+ expiredText.textContent = 'This link has expired';
4318
+ this.waitingSection.appendChild(expiredText);
4319
+ }
4320
+ // Hide countdown section
4321
+ if (this.countdownSection) {
4322
+ this.countdownSection.style.display = 'none';
4323
+ }
4324
+ }
4132
4325
  handleResend() {
4133
4326
  if (this.resendTimer?.isRunning() || !this.resendButton)
4134
4327
  return;
@@ -4255,8 +4448,9 @@ class IdentityRenderer {
4255
4448
  // View management
4256
4449
  this.currentView = null;
4257
4450
  this.focusTimeoutId = null;
4258
- // SparkLink polling
4451
+ // SparkLink polling and postMessage listener
4259
4452
  this.pollingInterval = null;
4453
+ this.messageListener = null;
4260
4454
  this.container = container;
4261
4455
  this.api = api;
4262
4456
  this.options = options;
@@ -4378,7 +4572,11 @@ class IdentityRenderer {
4378
4572
  this.currentView = view;
4379
4573
  clearChildren(body);
4380
4574
  body.appendChild(view.render());
4381
- const focusable = body.querySelector('input:not([disabled]), button:not([disabled])');
4575
+ // Auto-focus: prefer inputs first, then primary/method buttons (skip back links)
4576
+ const focusable = body.querySelector('input:not([disabled]), ' +
4577
+ 'button.sv-btn-primary:not([disabled]), ' +
4578
+ 'button.sv-btn-email-primary:not([disabled]), ' +
4579
+ 'button.sv-btn-method:not([disabled])');
4382
4580
  if (focusable) {
4383
4581
  // Clear any previous focus timeout to avoid stacking
4384
4582
  if (this.focusTimeoutId !== null) {
@@ -4451,6 +4649,7 @@ class IdentityRenderer {
4451
4649
  onResend: () => this.handleSparkLinkResend(),
4452
4650
  onFallback: () => this.handleSparkLinkFallback(),
4453
4651
  onBack: () => this.showMethodSelect(),
4652
+ onExpired: () => this.stopSparkLinkPolling(),
4454
4653
  });
4455
4654
  case 'oauth-pending':
4456
4655
  return new LoadingView({ message: `Connecting to ${state.provider}...` });
@@ -4793,41 +4992,73 @@ class IdentityRenderer {
4793
4992
  this.callbacks.onSuccess(pendingResult);
4794
4993
  }
4795
4994
  /**
4796
- * Start polling for SparkLink verification status.
4995
+ * Start listening for SparkLink verification completion.
4996
+ * Uses postMessage as primary mechanism (instant notification from ceremony page),
4997
+ * with polling as fallback for edge cases where postMessage might fail.
4797
4998
  */
4798
4999
  startSparkLinkPolling(sparkId) {
4799
5000
  this.stopSparkLinkPolling();
5001
+ // Primary: Listen for postMessage from ceremony page
5002
+ // This is faster and more reliable than polling
5003
+ this.messageListener = (event) => {
5004
+ // Validate message structure and type
5005
+ if (!event.data || typeof event.data !== 'object')
5006
+ return;
5007
+ if (event.data.type !== 'sparklink_verified')
5008
+ return;
5009
+ const { token, identity, identityType } = event.data;
5010
+ // Validate required fields
5011
+ if (!token || !identity)
5012
+ return;
5013
+ this.handleSparkLinkVerified({
5014
+ token,
5015
+ identity,
5016
+ identityType: identityType || 'email',
5017
+ });
5018
+ };
5019
+ window.addEventListener('message', this.messageListener);
5020
+ // Fallback: Poll status endpoint every 2 seconds
5021
+ // This catches cases where postMessage might not work (popup blockers, etc)
4800
5022
  this.pollingInterval = setInterval(async () => {
4801
5023
  const status = await this.sparkLinkHandler.checkStatus(sparkId);
4802
5024
  if (status.verified && status.token && status.identity) {
4803
- this.stopSparkLinkPolling();
4804
- const result = {
5025
+ this.handleSparkLinkVerified({
4805
5026
  token: status.token,
4806
5027
  identity: status.identity,
4807
5028
  identityType: status.identityType || 'email',
4808
- };
4809
- // Check if we should prompt for passkey registration
4810
- if (await this.shouldShowPasskeyPrompt()) {
4811
- this.setState({
4812
- view: 'passkey-prompt',
4813
- email: this.recipient,
4814
- pendingResult: result,
4815
- });
4816
- return;
4817
- }
4818
- this.close();
4819
- this.callbacks.onSuccess(result);
5029
+ });
4820
5030
  }
4821
5031
  }, 2000);
4822
5032
  }
4823
5033
  /**
4824
- * Stop SparkLink polling.
5034
+ * Handle successful SparkLink verification (from either postMessage or polling).
5035
+ */
5036
+ async handleSparkLinkVerified(result) {
5037
+ this.stopSparkLinkPolling();
5038
+ // Check if we should prompt for passkey registration
5039
+ if (await this.shouldShowPasskeyPrompt()) {
5040
+ this.setState({
5041
+ view: 'passkey-prompt',
5042
+ email: this.recipient,
5043
+ pendingResult: result,
5044
+ });
5045
+ return;
5046
+ }
5047
+ this.close();
5048
+ this.callbacks.onSuccess(result);
5049
+ }
5050
+ /**
5051
+ * Stop SparkLink polling and message listener.
4825
5052
  */
4826
5053
  stopSparkLinkPolling() {
4827
5054
  if (this.pollingInterval) {
4828
5055
  clearInterval(this.pollingInterval);
4829
5056
  this.pollingInterval = null;
4830
5057
  }
5058
+ if (this.messageListener) {
5059
+ window.removeEventListener('message', this.messageListener);
5060
+ this.messageListener = null;
5061
+ }
4831
5062
  }
4832
5063
  /**
4833
5064
  * Handle resending SparkLink.