@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.esm.js
CHANGED
|
@@ -726,7 +726,18 @@ class IdentityApi {
|
|
|
726
726
|
* Start passkey registration
|
|
727
727
|
*/
|
|
728
728
|
async startPasskeyRegister(email) {
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1271
|
+
const challengeResponse = await this.api.startPasskeyRegister(this.state.recipient);
|
|
1247
1272
|
const publicKeyOptions = {
|
|
1248
|
-
challenge: base64urlToArrayBuffer(
|
|
1273
|
+
challenge: base64urlToArrayBuffer(challengeResponse.challenge),
|
|
1249
1274
|
rp: {
|
|
1250
|
-
id:
|
|
1251
|
-
name:
|
|
1275
|
+
id: challengeResponse.rpId,
|
|
1276
|
+
name: challengeResponse.rpName,
|
|
1252
1277
|
},
|
|
1253
1278
|
user: {
|
|
1254
|
-
id: base64urlToArrayBuffer(
|
|
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:
|
|
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
|
-
|
|
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
|
|
1328
|
+
const challengeResponse = await this.api.startPasskeyVerify(this.state.recipient);
|
|
1304
1329
|
const publicKeyOptions = {
|
|
1305
|
-
challenge: base64urlToArrayBuffer(
|
|
1306
|
-
rpId:
|
|
1307
|
-
timeout:
|
|
1330
|
+
challenge: base64urlToArrayBuffer(challengeResponse.challenge),
|
|
1331
|
+
rpId: challengeResponse.rpId,
|
|
1332
|
+
timeout: challengeResponse.timeout,
|
|
1308
1333
|
userVerification: 'preferred',
|
|
1309
|
-
allowCredentials:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 = `
|
|
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
|
-
|
|
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-
|
|
3576
|
-
this.resendButton.
|
|
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
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
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);
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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.
|