@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.
- package/README.md +536 -502
- package/dist/identity/api.d.ts +4 -3
- package/dist/identity/renderer.d.ts +9 -2
- package/dist/identity/types.d.ts +2 -0
- package/dist/identity/views/icons.d.ts +8 -0
- package/dist/identity/views/sparklink-waiting.d.ts +7 -0
- package/dist/identity/views/totp-verify.d.ts +1 -1
- package/dist/sparkvault.cjs.js +304 -73
- package/dist/sparkvault.cjs.js.map +1 -1
- package/dist/sparkvault.esm.js +304 -73
- package/dist/sparkvault.esm.js.map +1 -1
- package/dist/sparkvault.js +1 -1
- package/dist/sparkvault.js.map +1 -1
- package/package.json +1 -1
package/dist/sparkvault.cjs.js
CHANGED
|
@@ -730,7 +730,18 @@ class IdentityApi {
|
|
|
730
730
|
* Start passkey registration
|
|
731
731
|
*/
|
|
732
732
|
async startPasskeyRegister(email) {
|
|
733
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1275
|
+
const challengeResponse = await this.api.startPasskeyRegister(this.state.recipient);
|
|
1251
1276
|
const publicKeyOptions = {
|
|
1252
|
-
challenge: base64urlToArrayBuffer(
|
|
1277
|
+
challenge: base64urlToArrayBuffer(challengeResponse.challenge),
|
|
1253
1278
|
rp: {
|
|
1254
|
-
id:
|
|
1255
|
-
name:
|
|
1279
|
+
id: challengeResponse.rpId,
|
|
1280
|
+
name: challengeResponse.rpName,
|
|
1256
1281
|
},
|
|
1257
1282
|
user: {
|
|
1258
|
-
id: base64urlToArrayBuffer(
|
|
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:
|
|
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
|
-
|
|
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
|
|
1332
|
+
const challengeResponse = await this.api.startPasskeyVerify(this.state.recipient);
|
|
1308
1333
|
const publicKeyOptions = {
|
|
1309
|
-
challenge: base64urlToArrayBuffer(
|
|
1310
|
-
rpId:
|
|
1311
|
-
timeout:
|
|
1334
|
+
challenge: base64urlToArrayBuffer(challengeResponse.challenge),
|
|
1335
|
+
rpId: challengeResponse.rpId,
|
|
1336
|
+
timeout: challengeResponse.timeout,
|
|
1312
1337
|
userVerification: 'preferred',
|
|
1313
|
-
allowCredentials:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 = `
|
|
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
|
-
|
|
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-
|
|
3580
|
-
this.resendButton.
|
|
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
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
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);
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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.
|