@sparkvault/sdk 1.1.6 → 1.8.3
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 +8 -8
- package/dist/auto-init.d.ts +11 -30
- package/dist/config.d.ts +3 -0
- package/dist/identity/api.d.ts +12 -8
- package/dist/identity/handlers/passkey-handler.d.ts +25 -8
- package/dist/identity/handlers/sparklink-handler.d.ts +4 -2
- package/dist/identity/index.d.ts +50 -32
- package/dist/identity/renderer.d.ts +8 -0
- package/dist/identity/state.d.ts +4 -1
- package/dist/identity/types.d.ts +54 -17
- package/dist/identity/views/icons.d.ts +1 -1
- package/dist/identity/views/passkey-prompt.d.ts +2 -0
- package/dist/index.d.ts +283 -248
- package/dist/sparkvault.cjs.js +3376 -904
- package/dist/sparkvault.cjs.js.map +1 -1
- package/dist/sparkvault.esm.js +3376 -904
- package/dist/sparkvault.esm.js.map +1 -1
- package/dist/sparkvault.js +1 -1
- package/dist/sparkvault.js.map +1 -1
- package/dist/utils/base64url.d.ts +1 -9
- package/dist/utils/dom.d.ts +11 -0
- package/dist/vaults/index.d.ts +27 -1
- package/dist/vaults/upload/api.d.ts +35 -0
- package/dist/vaults/upload/container.d.ts +57 -0
- package/dist/vaults/upload/index.d.ts +79 -0
- package/dist/vaults/upload/inline-container.d.ts +75 -0
- package/dist/vaults/upload/modal.d.ts +79 -0
- package/dist/vaults/upload/renderer.d.ts +55 -0
- package/dist/vaults/upload/styles.d.ts +20 -0
- package/dist/vaults/upload/types.d.ts +183 -0
- package/package.json +6 -7
- package/dist/rng/index.d.ts +0 -54
- package/dist/rng/types.d.ts +0 -26
- package/dist/sparks/index.d.ts +0 -37
- package/dist/sparks/types.d.ts +0 -56
package/dist/sparkvault.esm.js
CHANGED
|
@@ -73,6 +73,7 @@ function resolveConfig(config) {
|
|
|
73
73
|
apiBaseUrl: API_URL,
|
|
74
74
|
identityBaseUrl: IDENTITY_URL,
|
|
75
75
|
preloadConfig: config.preloadConfig !== false, // Default: true
|
|
76
|
+
backdropBlur: config.backdropBlur !== false, // Default: true
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
function validateConfig(config) {
|
|
@@ -98,7 +99,7 @@ function validateConfig(config) {
|
|
|
98
99
|
*
|
|
99
100
|
* Per CLAUDE.md §7: Retries allowed only if idempotent, MUST use exponential backoff.
|
|
100
101
|
*/
|
|
101
|
-
const DEFAULT_OPTIONS$
|
|
102
|
+
const DEFAULT_OPTIONS$2 = {
|
|
102
103
|
maxAttempts: 3,
|
|
103
104
|
baseDelayMs: 200,
|
|
104
105
|
maxDelayMs: 5000,
|
|
@@ -160,7 +161,7 @@ function sleep(ms) {
|
|
|
160
161
|
* );
|
|
161
162
|
*/
|
|
162
163
|
async function withRetry(fn, options = {}) {
|
|
163
|
-
const config = { ...DEFAULT_OPTIONS$
|
|
164
|
+
const config = { ...DEFAULT_OPTIONS$2, ...options };
|
|
164
165
|
let lastError;
|
|
165
166
|
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
|
|
166
167
|
try {
|
|
@@ -492,7 +493,7 @@ class ExpirationTimer {
|
|
|
492
493
|
* Base64URL Encoding/Decoding Utilities
|
|
493
494
|
*
|
|
494
495
|
* Centralized implementation per CLAUDE.md DRY enforcement.
|
|
495
|
-
* Used
|
|
496
|
+
* Used by Identity (WebAuthn) and Vaults modules.
|
|
496
497
|
*/
|
|
497
498
|
/**
|
|
498
499
|
* Encode Uint8Array to base64url string (URL-safe base64 without padding)
|
|
@@ -519,35 +520,6 @@ function base64urlEncode(data) {
|
|
|
519
520
|
function arrayBufferToBase64url(buffer) {
|
|
520
521
|
return base64urlEncode(new Uint8Array(buffer));
|
|
521
522
|
}
|
|
522
|
-
/**
|
|
523
|
-
* Decode base64url string to Uint8Array
|
|
524
|
-
*
|
|
525
|
-
* @param base64url - Base64url encoded string
|
|
526
|
-
* @returns Decoded bytes as Uint8Array
|
|
527
|
-
*/
|
|
528
|
-
function base64urlDecode(base64url) {
|
|
529
|
-
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
530
|
-
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
531
|
-
const binary = atob(padded);
|
|
532
|
-
const bytes = new Uint8Array(binary.length);
|
|
533
|
-
for (let i = 0; i < binary.length; i++) {
|
|
534
|
-
bytes[i] = binary.charCodeAt(i);
|
|
535
|
-
}
|
|
536
|
-
return bytes;
|
|
537
|
-
}
|
|
538
|
-
/**
|
|
539
|
-
* Decode base64url string to ArrayBuffer
|
|
540
|
-
*
|
|
541
|
-
* @param base64url - Base64url encoded string
|
|
542
|
-
* @returns Decoded ArrayBuffer
|
|
543
|
-
*/
|
|
544
|
-
function base64urlToArrayBuffer(base64url) {
|
|
545
|
-
const bytes = base64urlDecode(base64url);
|
|
546
|
-
// Create a new ArrayBuffer with exact copy (avoids TypeScript ArrayBufferLike issue)
|
|
547
|
-
const buffer = new ArrayBuffer(bytes.length);
|
|
548
|
-
new Uint8Array(buffer).set(bytes);
|
|
549
|
-
return buffer;
|
|
550
|
-
}
|
|
551
523
|
/**
|
|
552
524
|
* Decode base64url string to UTF-8 string
|
|
553
525
|
*
|
|
@@ -559,17 +531,6 @@ function base64urlToString(base64url) {
|
|
|
559
531
|
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
560
532
|
return atob(padded);
|
|
561
533
|
}
|
|
562
|
-
/**
|
|
563
|
-
* Decode base64url string to number array
|
|
564
|
-
* Used by RNG module for bytes format
|
|
565
|
-
*
|
|
566
|
-
* @param base64url - Base64url encoded string
|
|
567
|
-
* @returns Array of byte values
|
|
568
|
-
*/
|
|
569
|
-
function base64urlToBytes(base64url) {
|
|
570
|
-
const bytes = base64urlDecode(base64url);
|
|
571
|
-
return Array.from(bytes);
|
|
572
|
-
}
|
|
573
534
|
|
|
574
535
|
/**
|
|
575
536
|
* Identity Module Utilities
|
|
@@ -616,9 +577,9 @@ function generateState() {
|
|
|
616
577
|
* Single responsibility: API calls only.
|
|
617
578
|
*/
|
|
618
579
|
/** Default request timeout in milliseconds (30 seconds) */
|
|
619
|
-
const DEFAULT_TIMEOUT_MS = 30000;
|
|
580
|
+
const DEFAULT_TIMEOUT_MS$1 = 30000;
|
|
620
581
|
class IdentityApi {
|
|
621
|
-
constructor(config, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
582
|
+
constructor(config, timeoutMs = DEFAULT_TIMEOUT_MS$1) {
|
|
622
583
|
/** Cached config promise - allows preloading and deduplication */
|
|
623
584
|
this.configCache = null;
|
|
624
585
|
this.config = config;
|
|
@@ -703,6 +664,26 @@ class IdentityApi {
|
|
|
703
664
|
async checkPasskey(email) {
|
|
704
665
|
return this.request('POST', '/passkey/check', { email });
|
|
705
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* Build auth context params for API requests (OIDC/simple mode).
|
|
669
|
+
* Converts AuthContext to snake_case params expected by backend.
|
|
670
|
+
*/
|
|
671
|
+
buildAuthContextParams(authContext) {
|
|
672
|
+
if (!authContext)
|
|
673
|
+
return {};
|
|
674
|
+
const params = {};
|
|
675
|
+
if (authContext.authRequestId) {
|
|
676
|
+
params.auth_request_id = authContext.authRequestId;
|
|
677
|
+
}
|
|
678
|
+
if (authContext.simpleMode) {
|
|
679
|
+
params.simple_mode = {
|
|
680
|
+
success_url: authContext.simpleMode.successUrl,
|
|
681
|
+
failure_url: authContext.simpleMode.failureUrl,
|
|
682
|
+
state: authContext.simpleMode.state,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
return params;
|
|
686
|
+
}
|
|
706
687
|
/**
|
|
707
688
|
* Send TOTP code to email or phone
|
|
708
689
|
*/
|
|
@@ -710,6 +691,7 @@ class IdentityApi {
|
|
|
710
691
|
return this.request('POST', '/totp/send', {
|
|
711
692
|
recipient: params.recipient,
|
|
712
693
|
method: params.method,
|
|
694
|
+
...this.buildAuthContextParams(params.authContext),
|
|
713
695
|
});
|
|
714
696
|
}
|
|
715
697
|
/**
|
|
@@ -720,6 +702,7 @@ class IdentityApi {
|
|
|
720
702
|
kindling: params.kindling,
|
|
721
703
|
pin: params.pin,
|
|
722
704
|
recipient: params.recipient,
|
|
705
|
+
...this.buildAuthContextParams(params.authContext),
|
|
723
706
|
});
|
|
724
707
|
}
|
|
725
708
|
/**
|
|
@@ -760,10 +743,10 @@ class IdentityApi {
|
|
|
760
743
|
/**
|
|
761
744
|
* Start passkey verification
|
|
762
745
|
*/
|
|
763
|
-
async startPasskeyVerify(email) {
|
|
746
|
+
async startPasskeyVerify(email, authContext) {
|
|
764
747
|
// Backend returns { options: PublicKeyCredentialRequestOptions, session: {...} }
|
|
765
748
|
// Extract and flatten to match PasskeyChallengeResponse
|
|
766
|
-
const response = await this.request('POST', '/passkey/verify', { email });
|
|
749
|
+
const response = await this.request('POST', '/passkey/verify', { email, ...this.buildAuthContextParams(authContext) });
|
|
767
750
|
return {
|
|
768
751
|
challenge: response.options.challenge,
|
|
769
752
|
rpId: response.options.rpId,
|
|
@@ -780,6 +763,7 @@ class IdentityApi {
|
|
|
780
763
|
const assertion = params.credential.response;
|
|
781
764
|
return this.request('POST', '/passkey/verify/complete', {
|
|
782
765
|
session: params.session,
|
|
766
|
+
...this.buildAuthContextParams(params.authContext),
|
|
783
767
|
credential: {
|
|
784
768
|
id: params.credential.id,
|
|
785
769
|
rawId: arrayBufferToBase64url(params.credential.rawId),
|
|
@@ -805,33 +789,26 @@ class IdentityApi {
|
|
|
805
789
|
});
|
|
806
790
|
return `${this.baseUrl}/social/${provider}?${params}`;
|
|
807
791
|
}
|
|
808
|
-
/**
|
|
809
|
-
* Get SAML redirect URL for enterprise provider
|
|
810
|
-
*/
|
|
811
|
-
getEnterpriseAuthUrl(provider, redirectUri, state) {
|
|
812
|
-
const params = new URLSearchParams({
|
|
813
|
-
redirect_uri: redirectUri,
|
|
814
|
-
state,
|
|
815
|
-
});
|
|
816
|
-
return `${this.baseUrl}/saml/${provider}?${params}`;
|
|
817
|
-
}
|
|
818
792
|
/**
|
|
819
793
|
* Send SparkLink email for identity verification.
|
|
820
794
|
* Includes openerOrigin for postMessage-based completion notification.
|
|
821
795
|
*/
|
|
822
|
-
async sendSparkLink(email) {
|
|
796
|
+
async sendSparkLink(email, authContext) {
|
|
823
797
|
return this.request('POST', '/sparklink/send', {
|
|
824
798
|
email,
|
|
825
799
|
type: 'verify_identity',
|
|
826
800
|
// Send opener origin for postMessage on verification completion
|
|
827
801
|
// This allows the ceremony page to notify the SDK directly instead of polling
|
|
828
802
|
openerOrigin: typeof window !== 'undefined' ? window.location.origin : undefined,
|
|
803
|
+
...this.buildAuthContextParams(authContext),
|
|
829
804
|
});
|
|
830
805
|
}
|
|
831
806
|
/**
|
|
832
807
|
* Check SparkLink verification status (polling endpoint)
|
|
833
808
|
*/
|
|
834
|
-
async checkSparkLinkStatus(sparkId) {
|
|
809
|
+
async checkSparkLinkStatus(sparkId, _authContext) {
|
|
810
|
+
// For GET requests with auth context, we'd need query params, but the status endpoint
|
|
811
|
+
// doesn't need auth context - it's already stored in the sparklink record
|
|
835
812
|
return this.request('GET', `/sparklink/status/${sparkId}`);
|
|
836
813
|
}
|
|
837
814
|
}
|
|
@@ -889,10 +866,10 @@ const METHOD_REGISTRY = {
|
|
|
889
866
|
},
|
|
890
867
|
sparklink: {
|
|
891
868
|
id: 'sparklink',
|
|
892
|
-
type: '
|
|
869
|
+
type: 'sparklink',
|
|
893
870
|
identityType: 'email',
|
|
894
871
|
name: 'SparkLink',
|
|
895
|
-
description: 'Get an instant sign-in
|
|
872
|
+
description: 'Get an instant sign-in link via email',
|
|
896
873
|
icon: 'link',
|
|
897
874
|
},
|
|
898
875
|
// Social providers
|
|
@@ -950,98 +927,6 @@ const METHOD_REGISTRY = {
|
|
|
950
927
|
icon: 'linkedin',
|
|
951
928
|
provider: 'linkedin',
|
|
952
929
|
},
|
|
953
|
-
// Enterprise SSO
|
|
954
|
-
enterprise_okta: {
|
|
955
|
-
id: 'enterprise_okta',
|
|
956
|
-
type: 'enterprise',
|
|
957
|
-
identityType: 'email',
|
|
958
|
-
name: 'Okta',
|
|
959
|
-
description: 'Sign in with Okta SSO',
|
|
960
|
-
icon: 'okta',
|
|
961
|
-
provider: 'okta',
|
|
962
|
-
},
|
|
963
|
-
enterprise_entra: {
|
|
964
|
-
id: 'enterprise_entra',
|
|
965
|
-
type: 'enterprise',
|
|
966
|
-
identityType: 'email',
|
|
967
|
-
name: 'Microsoft Entra',
|
|
968
|
-
description: 'Sign in with Microsoft Entra ID',
|
|
969
|
-
icon: 'microsoft',
|
|
970
|
-
provider: 'entra',
|
|
971
|
-
},
|
|
972
|
-
enterprise_onelogin: {
|
|
973
|
-
id: 'enterprise_onelogin',
|
|
974
|
-
type: 'enterprise',
|
|
975
|
-
identityType: 'email',
|
|
976
|
-
name: 'OneLogin',
|
|
977
|
-
description: 'Sign in with OneLogin SSO',
|
|
978
|
-
icon: 'onelogin',
|
|
979
|
-
provider: 'onelogin',
|
|
980
|
-
},
|
|
981
|
-
enterprise_ping: {
|
|
982
|
-
id: 'enterprise_ping',
|
|
983
|
-
type: 'enterprise',
|
|
984
|
-
identityType: 'email',
|
|
985
|
-
name: 'Ping Identity',
|
|
986
|
-
description: 'Sign in with Ping Identity',
|
|
987
|
-
icon: 'ping',
|
|
988
|
-
provider: 'ping',
|
|
989
|
-
},
|
|
990
|
-
enterprise_jumpcloud: {
|
|
991
|
-
id: 'enterprise_jumpcloud',
|
|
992
|
-
type: 'enterprise',
|
|
993
|
-
identityType: 'email',
|
|
994
|
-
name: 'JumpCloud',
|
|
995
|
-
description: 'Sign in with JumpCloud SSO',
|
|
996
|
-
icon: 'jumpcloud',
|
|
997
|
-
provider: 'jumpcloud',
|
|
998
|
-
},
|
|
999
|
-
// HRIS
|
|
1000
|
-
hris_bamboohr: {
|
|
1001
|
-
id: 'hris_bamboohr',
|
|
1002
|
-
type: 'hris',
|
|
1003
|
-
identityType: 'email',
|
|
1004
|
-
name: 'BambooHR',
|
|
1005
|
-
description: 'Sign in with BambooHR',
|
|
1006
|
-
icon: 'bamboohr',
|
|
1007
|
-
provider: 'bamboohr',
|
|
1008
|
-
},
|
|
1009
|
-
hris_workday: {
|
|
1010
|
-
id: 'hris_workday',
|
|
1011
|
-
type: 'hris',
|
|
1012
|
-
identityType: 'email',
|
|
1013
|
-
name: 'Workday',
|
|
1014
|
-
description: 'Sign in with Workday',
|
|
1015
|
-
icon: 'workday',
|
|
1016
|
-
provider: 'workday',
|
|
1017
|
-
},
|
|
1018
|
-
hris_adp: {
|
|
1019
|
-
id: 'hris_adp',
|
|
1020
|
-
type: 'hris',
|
|
1021
|
-
identityType: 'email',
|
|
1022
|
-
name: 'ADP',
|
|
1023
|
-
description: 'Sign in with ADP',
|
|
1024
|
-
icon: 'adp',
|
|
1025
|
-
provider: 'adp',
|
|
1026
|
-
},
|
|
1027
|
-
hris_gusto: {
|
|
1028
|
-
id: 'hris_gusto',
|
|
1029
|
-
type: 'hris',
|
|
1030
|
-
identityType: 'email',
|
|
1031
|
-
name: 'Gusto',
|
|
1032
|
-
description: 'Sign in with Gusto',
|
|
1033
|
-
icon: 'gusto',
|
|
1034
|
-
provider: 'gusto',
|
|
1035
|
-
},
|
|
1036
|
-
hris_rippling: {
|
|
1037
|
-
id: 'hris_rippling',
|
|
1038
|
-
type: 'hris',
|
|
1039
|
-
identityType: 'email',
|
|
1040
|
-
name: 'Rippling',
|
|
1041
|
-
description: 'Sign in with Rippling',
|
|
1042
|
-
icon: 'rippling',
|
|
1043
|
-
provider: 'rippling',
|
|
1044
|
-
},
|
|
1045
930
|
};
|
|
1046
931
|
/**
|
|
1047
932
|
* Enrich method IDs with full metadata
|
|
@@ -1075,6 +960,8 @@ class VerificationState {
|
|
|
1075
960
|
// Identity being verified
|
|
1076
961
|
this._recipient = '';
|
|
1077
962
|
this._identityType = 'email';
|
|
963
|
+
// Auth context for OIDC/simple mode flows
|
|
964
|
+
this._authContext = undefined;
|
|
1078
965
|
// Passkey state
|
|
1079
966
|
this._passkey = {
|
|
1080
967
|
hasPasskey: null,
|
|
@@ -1162,6 +1049,13 @@ class VerificationState {
|
|
|
1162
1049
|
const allowed = this._sdkConfig?.allowedIdentityTypes ?? ['email'];
|
|
1163
1050
|
return allowed;
|
|
1164
1051
|
}
|
|
1052
|
+
// --- Auth Context (OIDC/simple mode) ---
|
|
1053
|
+
get authContext() {
|
|
1054
|
+
return this._authContext;
|
|
1055
|
+
}
|
|
1056
|
+
setAuthContext(context) {
|
|
1057
|
+
this._authContext = context;
|
|
1058
|
+
}
|
|
1165
1059
|
// --- Passkey State ---
|
|
1166
1060
|
get passkey() {
|
|
1167
1061
|
return this._passkey;
|
|
@@ -1222,6 +1116,7 @@ class VerificationState {
|
|
|
1222
1116
|
this._viewState = { view: 'loading' };
|
|
1223
1117
|
this._recipient = '';
|
|
1224
1118
|
this._identityType = 'email';
|
|
1119
|
+
this._authContext = undefined;
|
|
1225
1120
|
this._passkey.hasPasskey = null;
|
|
1226
1121
|
this._passkey.isFallbackMode = false;
|
|
1227
1122
|
this._passkey.pendingResult = null;
|
|
@@ -1233,16 +1128,35 @@ class VerificationState {
|
|
|
1233
1128
|
/**
|
|
1234
1129
|
* Passkey Handler
|
|
1235
1130
|
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1131
|
+
* Handles WebAuthn passkey registration and verification via popup.
|
|
1132
|
+
* Uses a popup window on sparkvault.com domain to ensure WebAuthn works
|
|
1133
|
+
* correctly when the SDK is embedded on third-party client domains.
|
|
1134
|
+
*
|
|
1135
|
+
* Flow:
|
|
1136
|
+
* 1. SDK opens popup to sparkvault.com/passkey/popup
|
|
1137
|
+
* 2. Popup executes WebAuthn (origin = sparkvault.com matches RP ID)
|
|
1138
|
+
* 3. Popup sends result via postMessage
|
|
1139
|
+
* 4. SDK receives result and returns to caller
|
|
1238
1140
|
*/
|
|
1141
|
+
/** Popup window dimensions */
|
|
1142
|
+
const POPUP_WIDTH = 450;
|
|
1143
|
+
const POPUP_HEIGHT = 500;
|
|
1144
|
+
/** Timeout for popup operations (60 seconds) */
|
|
1145
|
+
const POPUP_TIMEOUT_MS = 60000;
|
|
1239
1146
|
/**
|
|
1240
|
-
* Handles WebAuthn passkey registration and verification
|
|
1147
|
+
* Handles WebAuthn passkey registration and verification via popup
|
|
1241
1148
|
*/
|
|
1242
1149
|
class PasskeyHandler {
|
|
1243
1150
|
constructor(api, state) {
|
|
1244
1151
|
this.api = api;
|
|
1245
1152
|
this.state = state;
|
|
1153
|
+
// Extract base URL and account ID from the API's URL construction
|
|
1154
|
+
// The API uses: `${this.config.identityBaseUrl}/${this.config.accountId}`
|
|
1155
|
+
// We need to access these values for popup URL construction
|
|
1156
|
+
// @ts-expect-error - accessing private config for URL construction
|
|
1157
|
+
const config = api.config;
|
|
1158
|
+
this.baseUrl = config.identityBaseUrl;
|
|
1159
|
+
this.accountId = config.accountId;
|
|
1246
1160
|
}
|
|
1247
1161
|
/**
|
|
1248
1162
|
* Check if user has registered passkeys and validate email domain
|
|
@@ -1264,119 +1178,155 @@ class PasskeyHandler {
|
|
|
1264
1178
|
}
|
|
1265
1179
|
}
|
|
1266
1180
|
/**
|
|
1267
|
-
* Register a new passkey for the user
|
|
1181
|
+
* Register a new passkey for the user via popup
|
|
1268
1182
|
*/
|
|
1269
1183
|
async register() {
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
const publicKeyOptions = {
|
|
1273
|
-
challenge: base64urlToArrayBuffer(challengeResponse.challenge),
|
|
1274
|
-
rp: {
|
|
1275
|
-
id: challengeResponse.rpId,
|
|
1276
|
-
name: challengeResponse.rpName,
|
|
1277
|
-
},
|
|
1278
|
-
user: {
|
|
1279
|
-
id: base64urlToArrayBuffer(challengeResponse.userId ?? this.state.recipient),
|
|
1280
|
-
name: this.state.recipient,
|
|
1281
|
-
displayName: this.state.recipient,
|
|
1282
|
-
},
|
|
1283
|
-
pubKeyCredParams: [
|
|
1284
|
-
{ type: 'public-key', alg: -7 }, // ES256
|
|
1285
|
-
{ type: 'public-key', alg: -257 }, // RS256
|
|
1286
|
-
],
|
|
1287
|
-
timeout: challengeResponse.timeout,
|
|
1288
|
-
authenticatorSelection: {
|
|
1289
|
-
residentKey: 'preferred',
|
|
1290
|
-
userVerification: 'preferred',
|
|
1291
|
-
},
|
|
1292
|
-
attestation: 'none',
|
|
1293
|
-
};
|
|
1294
|
-
const credential = await navigator.credentials.create({
|
|
1295
|
-
publicKey: publicKeyOptions,
|
|
1296
|
-
});
|
|
1297
|
-
if (!credential) {
|
|
1298
|
-
return {
|
|
1299
|
-
success: false,
|
|
1300
|
-
error: 'Failed to create passkey',
|
|
1301
|
-
errorType: 'unknown',
|
|
1302
|
-
};
|
|
1303
|
-
}
|
|
1304
|
-
const response = await this.api.completePasskeyRegister({
|
|
1305
|
-
session: challengeResponse.session,
|
|
1306
|
-
credential,
|
|
1307
|
-
});
|
|
1184
|
+
const result = await this.openPasskeyPopup('register');
|
|
1185
|
+
if (result.success) {
|
|
1308
1186
|
// Update state - user now has a passkey
|
|
1309
1187
|
this.state.setPasskeyStatus(true);
|
|
1310
|
-
return {
|
|
1311
|
-
success: true,
|
|
1312
|
-
result: {
|
|
1313
|
-
token: response.token,
|
|
1314
|
-
identity: response.identity,
|
|
1315
|
-
identityType: response.identity_type,
|
|
1316
|
-
},
|
|
1317
|
-
};
|
|
1318
|
-
}
|
|
1319
|
-
catch (error) {
|
|
1320
|
-
return this.handleError(error);
|
|
1321
1188
|
}
|
|
1189
|
+
return result;
|
|
1322
1190
|
}
|
|
1323
1191
|
/**
|
|
1324
|
-
* Verify user with existing passkey
|
|
1192
|
+
* Verify user with existing passkey via popup
|
|
1325
1193
|
*/
|
|
1326
1194
|
async verify() {
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
};
|
|
1340
|
-
const credential = await navigator.credentials.get({
|
|
1341
|
-
publicKey: publicKeyOptions,
|
|
1195
|
+
return this.openPasskeyPopup('verify');
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Open passkey popup and wait for result
|
|
1199
|
+
*/
|
|
1200
|
+
async openPasskeyPopup(mode) {
|
|
1201
|
+
return new Promise((resolve) => {
|
|
1202
|
+
// Build popup URL
|
|
1203
|
+
const params = new URLSearchParams({
|
|
1204
|
+
mode,
|
|
1205
|
+
email: this.state.recipient,
|
|
1206
|
+
origin: window.location.origin,
|
|
1342
1207
|
});
|
|
1343
|
-
|
|
1344
|
-
|
|
1208
|
+
// Add auth context for OIDC/simple mode flows
|
|
1209
|
+
const authContext = this.state.authContext;
|
|
1210
|
+
if (authContext?.authRequestId) {
|
|
1211
|
+
params.set('auth_request_id', authContext.authRequestId);
|
|
1212
|
+
}
|
|
1213
|
+
if (authContext?.simpleMode) {
|
|
1214
|
+
params.set('simple_mode', 'true');
|
|
1215
|
+
params.set('success_url', authContext.simpleMode.successUrl);
|
|
1216
|
+
params.set('failure_url', authContext.simpleMode.failureUrl);
|
|
1217
|
+
if (authContext.simpleMode.state) {
|
|
1218
|
+
params.set('state', authContext.simpleMode.state);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
const popupUrl = `${this.baseUrl}/${this.accountId}/passkey/popup?${params}`;
|
|
1222
|
+
// Calculate popup position (centered)
|
|
1223
|
+
const left = Math.round((window.screen.width - POPUP_WIDTH) / 2);
|
|
1224
|
+
const top = Math.round((window.screen.height - POPUP_HEIGHT) / 2);
|
|
1225
|
+
// Open popup
|
|
1226
|
+
const popup = window.open(popupUrl, 'sparkvault-passkey', `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},popup=1`);
|
|
1227
|
+
if (!popup) {
|
|
1228
|
+
resolve({
|
|
1345
1229
|
success: false,
|
|
1346
|
-
error: '
|
|
1347
|
-
errorType: '
|
|
1348
|
-
};
|
|
1230
|
+
error: 'Popup was blocked. Please allow popups for this site.',
|
|
1231
|
+
errorType: 'popup_blocked',
|
|
1232
|
+
});
|
|
1233
|
+
return;
|
|
1349
1234
|
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1235
|
+
// Set up message listener
|
|
1236
|
+
let resolved = false;
|
|
1237
|
+
const cleanup = () => {
|
|
1238
|
+
window.removeEventListener('message', handleMessage);
|
|
1239
|
+
clearTimeout(timeoutId);
|
|
1240
|
+
clearInterval(pollClosedId);
|
|
1241
|
+
};
|
|
1242
|
+
const handleMessage = (event) => {
|
|
1243
|
+
// Validate origin - must be from sparkvault.com
|
|
1244
|
+
if (!this.isValidMessageOrigin(event.origin)) {
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const data = event.data;
|
|
1248
|
+
// Verify message type
|
|
1249
|
+
if (data?.type !== 'sparkvault-passkey-result') {
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
resolved = true;
|
|
1253
|
+
cleanup();
|
|
1254
|
+
if (data.success && data.data) {
|
|
1255
|
+
resolve({
|
|
1256
|
+
success: true,
|
|
1257
|
+
result: {
|
|
1258
|
+
token: data.data.token || '',
|
|
1259
|
+
identity: data.data.identity || this.state.recipient,
|
|
1260
|
+
identityType: data.data.identity_type || 'email',
|
|
1261
|
+
redirect: data.data.redirect,
|
|
1262
|
+
},
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
resolve(this.handlePopupError(data.error, data.message));
|
|
1267
|
+
}
|
|
1361
1268
|
};
|
|
1269
|
+
window.addEventListener('message', handleMessage);
|
|
1270
|
+
// Timeout after 60 seconds
|
|
1271
|
+
const timeoutId = setTimeout(() => {
|
|
1272
|
+
if (!resolved) {
|
|
1273
|
+
resolved = true;
|
|
1274
|
+
cleanup();
|
|
1275
|
+
popup.close();
|
|
1276
|
+
resolve({
|
|
1277
|
+
success: false,
|
|
1278
|
+
error: 'Passkey operation timed out. Please try again.',
|
|
1279
|
+
errorType: 'unknown',
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}, POPUP_TIMEOUT_MS);
|
|
1283
|
+
// Poll for popup being closed manually
|
|
1284
|
+
const pollClosedId = setInterval(() => {
|
|
1285
|
+
if (popup.closed && !resolved) {
|
|
1286
|
+
resolved = true;
|
|
1287
|
+
cleanup();
|
|
1288
|
+
resolve({
|
|
1289
|
+
success: false,
|
|
1290
|
+
error: 'Authentication was cancelled.',
|
|
1291
|
+
errorType: 'cancelled',
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}, 500);
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Validate that a message origin is from sparkvault.com domain
|
|
1299
|
+
*/
|
|
1300
|
+
isValidMessageOrigin(origin) {
|
|
1301
|
+
try {
|
|
1302
|
+
const url = new URL(origin);
|
|
1303
|
+
const hostname = url.hostname;
|
|
1304
|
+
// Allow sparkvault.com and subdomains
|
|
1305
|
+
if (hostname === 'sparkvault.com' || hostname.endsWith('.sparkvault.com')) {
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
// Allow localhost for development
|
|
1309
|
+
if (hostname === 'localhost') {
|
|
1310
|
+
return true;
|
|
1311
|
+
}
|
|
1312
|
+
return false;
|
|
1362
1313
|
}
|
|
1363
|
-
catch
|
|
1364
|
-
return
|
|
1314
|
+
catch {
|
|
1315
|
+
return false;
|
|
1365
1316
|
}
|
|
1366
1317
|
}
|
|
1367
1318
|
/**
|
|
1368
|
-
* Handle
|
|
1319
|
+
* Handle popup error response
|
|
1369
1320
|
*/
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
if (message.includes('not allowed') || message.includes('cancelled')) {
|
|
1321
|
+
handlePopupError(error, message) {
|
|
1322
|
+
if (error === 'cancelled') {
|
|
1373
1323
|
return {
|
|
1374
1324
|
success: false,
|
|
1375
|
-
error: 'Authentication was cancelled. Please try again.',
|
|
1325
|
+
error: message || 'Authentication was cancelled. Please try again.',
|
|
1376
1326
|
errorType: 'cancelled',
|
|
1377
1327
|
};
|
|
1378
1328
|
}
|
|
1379
|
-
if (message
|
|
1329
|
+
if (message?.includes('No passkey') || message?.includes('not found')) {
|
|
1380
1330
|
return {
|
|
1381
1331
|
success: false,
|
|
1382
1332
|
error: 'No passkey found. Create one to continue.',
|
|
@@ -1385,7 +1335,7 @@ class PasskeyHandler {
|
|
|
1385
1335
|
}
|
|
1386
1336
|
return {
|
|
1387
1337
|
success: false,
|
|
1388
|
-
error: message || 'Passkey authentication failed',
|
|
1338
|
+
error: message || error || 'Passkey authentication failed',
|
|
1389
1339
|
errorType: 'unknown',
|
|
1390
1340
|
};
|
|
1391
1341
|
}
|
|
@@ -1413,6 +1363,7 @@ class TotpHandler {
|
|
|
1413
1363
|
const response = await this.api.sendTotp({
|
|
1414
1364
|
recipient: this.state.recipient,
|
|
1415
1365
|
method,
|
|
1366
|
+
authContext: this.state.authContext,
|
|
1416
1367
|
});
|
|
1417
1368
|
// Update state with kindling
|
|
1418
1369
|
this.state.setKindling(response.kindling);
|
|
@@ -1446,6 +1397,7 @@ class TotpHandler {
|
|
|
1446
1397
|
const response = await this.api.sendTotp({
|
|
1447
1398
|
recipient: this.state.recipient,
|
|
1448
1399
|
method,
|
|
1400
|
+
authContext: this.state.authContext,
|
|
1449
1401
|
});
|
|
1450
1402
|
// Update state with new kindling
|
|
1451
1403
|
this.state.setKindling(response.kindling);
|
|
@@ -1479,6 +1431,7 @@ class TotpHandler {
|
|
|
1479
1431
|
kindling,
|
|
1480
1432
|
pin: code,
|
|
1481
1433
|
recipient: this.state.recipient,
|
|
1434
|
+
authContext: this.state.authContext,
|
|
1482
1435
|
});
|
|
1483
1436
|
if (response.verified && response.token) {
|
|
1484
1437
|
return {
|
|
@@ -1487,6 +1440,7 @@ class TotpHandler {
|
|
|
1487
1440
|
token: response.token,
|
|
1488
1441
|
identity: response.identity ?? this.state.recipient,
|
|
1489
1442
|
identityType: response.identity_type ?? this.state.identityType,
|
|
1443
|
+
redirect: response.redirect,
|
|
1490
1444
|
},
|
|
1491
1445
|
};
|
|
1492
1446
|
}
|
|
@@ -1515,11 +1469,11 @@ class TotpHandler {
|
|
|
1515
1469
|
/**
|
|
1516
1470
|
* SparkLink Handler
|
|
1517
1471
|
*
|
|
1518
|
-
* Single responsibility: SparkLink
|
|
1472
|
+
* Single responsibility: SparkLink sending and status polling.
|
|
1519
1473
|
* Extracts SparkLink logic from IdentityRenderer for better separation of concerns.
|
|
1520
1474
|
*/
|
|
1521
1475
|
/**
|
|
1522
|
-
* Handles SparkLink
|
|
1476
|
+
* Handles SparkLink sending and verification polling
|
|
1523
1477
|
*/
|
|
1524
1478
|
class SparkLinkHandler {
|
|
1525
1479
|
constructor(api, state) {
|
|
@@ -1531,7 +1485,7 @@ class SparkLinkHandler {
|
|
|
1531
1485
|
*/
|
|
1532
1486
|
async send() {
|
|
1533
1487
|
try {
|
|
1534
|
-
const result = await this.api.sendSparkLink(this.state.recipient);
|
|
1488
|
+
const result = await this.api.sendSparkLink(this.state.recipient, this.state.authContext);
|
|
1535
1489
|
return {
|
|
1536
1490
|
success: true,
|
|
1537
1491
|
sparkId: result.sparkId,
|
|
@@ -1558,6 +1512,7 @@ class SparkLinkHandler {
|
|
|
1558
1512
|
token: result.token,
|
|
1559
1513
|
identity: result.identity,
|
|
1560
1514
|
identityType: result.identityType,
|
|
1515
|
+
redirect: result.redirect,
|
|
1561
1516
|
};
|
|
1562
1517
|
}
|
|
1563
1518
|
catch {
|
|
@@ -1577,18 +1532,18 @@ class SparkLinkHandler {
|
|
|
1577
1532
|
* - Professional typography with clear hierarchy
|
|
1578
1533
|
* - Refined color palette with single accent
|
|
1579
1534
|
*/
|
|
1580
|
-
const STYLE_ID = 'sparkvault-identity-styles';
|
|
1535
|
+
const STYLE_ID$1 = 'sparkvault-identity-styles';
|
|
1581
1536
|
/**
|
|
1582
1537
|
* Inject modal styles into the document head.
|
|
1583
1538
|
*/
|
|
1584
1539
|
function injectStyles(options) {
|
|
1585
|
-
if (document.getElementById(STYLE_ID)) {
|
|
1540
|
+
if (document.getElementById(STYLE_ID$1)) {
|
|
1586
1541
|
updateThemeVariables(options.branding);
|
|
1587
1542
|
updateBlur(options.backdropBlur);
|
|
1588
1543
|
return;
|
|
1589
1544
|
}
|
|
1590
1545
|
const style = document.createElement('style');
|
|
1591
|
-
style.id = STYLE_ID;
|
|
1546
|
+
style.id = STYLE_ID$1;
|
|
1592
1547
|
style.textContent = getStyles(options);
|
|
1593
1548
|
document.head.appendChild(style);
|
|
1594
1549
|
}
|
|
@@ -1677,7 +1632,6 @@ function getStyles(options) {
|
|
|
1677
1632
|
width: 100%;
|
|
1678
1633
|
max-width: 400px;
|
|
1679
1634
|
max-height: calc(100vh - 32px);
|
|
1680
|
-
overflow: hidden;
|
|
1681
1635
|
display: flex;
|
|
1682
1636
|
flex-direction: column;
|
|
1683
1637
|
color: ${tokens.textPrimary};
|
|
@@ -1756,7 +1710,7 @@ function getStyles(options) {
|
|
|
1756
1710
|
======================================== */
|
|
1757
1711
|
|
|
1758
1712
|
.sv-body {
|
|
1759
|
-
padding: 24px;
|
|
1713
|
+
padding: 24px 28px;
|
|
1760
1714
|
overflow-y: auto;
|
|
1761
1715
|
flex: 1;
|
|
1762
1716
|
}
|
|
@@ -2566,7 +2520,9 @@ function getStyles(options) {
|
|
|
2566
2520
|
.sv-inline-container {
|
|
2567
2521
|
display: flex;
|
|
2568
2522
|
flex-direction: column;
|
|
2523
|
+
width: 100%;
|
|
2569
2524
|
height: 100%;
|
|
2525
|
+
box-sizing: border-box;
|
|
2570
2526
|
background: ${tokens.bg};
|
|
2571
2527
|
color: ${tokens.textPrimary};
|
|
2572
2528
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
@@ -2574,7 +2530,6 @@ function getStyles(options) {
|
|
|
2574
2530
|
-moz-osx-font-smoothing: grayscale;
|
|
2575
2531
|
border-radius: 12px;
|
|
2576
2532
|
border: 1px solid ${tokens.border};
|
|
2577
|
-
overflow: hidden;
|
|
2578
2533
|
}
|
|
2579
2534
|
|
|
2580
2535
|
.sv-inline-header {
|
|
@@ -2605,7 +2560,7 @@ function getStyles(options) {
|
|
|
2605
2560
|
}
|
|
2606
2561
|
|
|
2607
2562
|
.sv-body {
|
|
2608
|
-
padding: 24px;
|
|
2563
|
+
padding: 24px 28px;
|
|
2609
2564
|
}
|
|
2610
2565
|
|
|
2611
2566
|
.sv-totp-digit {
|
|
@@ -2705,7 +2660,7 @@ function createCloseIcon() {
|
|
|
2705
2660
|
return svg;
|
|
2706
2661
|
}
|
|
2707
2662
|
/**
|
|
2708
|
-
* Passkey icon
|
|
2663
|
+
* Passkey icon - simple key design
|
|
2709
2664
|
*/
|
|
2710
2665
|
function createPasskeyIcon() {
|
|
2711
2666
|
const svg = createSvgElement('0 0 56 56', 56, 56);
|
|
@@ -2716,10 +2671,23 @@ function createPasskeyIcon() {
|
|
|
2716
2671
|
stroke: '#E0E7FF',
|
|
2717
2672
|
'stroke-width': '1',
|
|
2718
2673
|
}));
|
|
2719
|
-
// Key icon
|
|
2674
|
+
// Key icon - centered at 28,28
|
|
2720
2675
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
2721
|
-
group.setAttribute('transform', 'translate(
|
|
2722
|
-
|
|
2676
|
+
group.setAttribute('transform', 'translate(28, 28)');
|
|
2677
|
+
const strokeAttrs = {
|
|
2678
|
+
stroke: '#4F46E5',
|
|
2679
|
+
'stroke-width': '2.5',
|
|
2680
|
+
'stroke-linecap': 'round',
|
|
2681
|
+
'stroke-linejoin': 'round',
|
|
2682
|
+
fill: 'none',
|
|
2683
|
+
};
|
|
2684
|
+
// Key head (circle)
|
|
2685
|
+
group.appendChild(createCircle(-6, -6, 6, strokeAttrs));
|
|
2686
|
+
// Key shaft
|
|
2687
|
+
group.appendChild(createPath('M-1.5 -1.5L10 10', strokeAttrs));
|
|
2688
|
+
// Key teeth
|
|
2689
|
+
group.appendChild(createPath('M6 6L6 10', strokeAttrs));
|
|
2690
|
+
group.appendChild(createPath('M10 10L10 14', strokeAttrs));
|
|
2723
2691
|
svg.appendChild(group);
|
|
2724
2692
|
return svg;
|
|
2725
2693
|
}
|
|
@@ -2926,13 +2894,13 @@ function createUsersIcon() {
|
|
|
2926
2894
|
* Creates, shows, hides, and destroys the modal overlay and container.
|
|
2927
2895
|
* Does NOT handle content rendering - that's the Renderer's job.
|
|
2928
2896
|
*/
|
|
2929
|
-
const OVERLAY_ID = 'sparkvault-identity-overlay';
|
|
2930
|
-
const MODAL_ID = 'sparkvault-identity-modal';
|
|
2897
|
+
const OVERLAY_ID$1 = 'sparkvault-identity-overlay';
|
|
2898
|
+
const MODAL_ID$1 = 'sparkvault-identity-modal';
|
|
2931
2899
|
/**
|
|
2932
2900
|
* Default branding used for immediate modal display before config loads.
|
|
2933
2901
|
* Uses neutral styling that works with both light and dark themes.
|
|
2934
2902
|
*/
|
|
2935
|
-
const DEFAULT_BRANDING = {
|
|
2903
|
+
const DEFAULT_BRANDING$1 = {
|
|
2936
2904
|
companyName: '',
|
|
2937
2905
|
logoLight: null,
|
|
2938
2906
|
logoDark: null,
|
|
@@ -2956,7 +2924,7 @@ class ModalContainer {
|
|
|
2956
2924
|
*/
|
|
2957
2925
|
createLoading(options, onClose) {
|
|
2958
2926
|
return this.create({
|
|
2959
|
-
branding: DEFAULT_BRANDING,
|
|
2927
|
+
branding: DEFAULT_BRANDING$1,
|
|
2960
2928
|
backdropBlur: options.backdropBlur,
|
|
2961
2929
|
}, onClose);
|
|
2962
2930
|
}
|
|
@@ -3007,13 +2975,13 @@ class ModalContainer {
|
|
|
3007
2975
|
};
|
|
3008
2976
|
injectStyles(styleOptions);
|
|
3009
2977
|
const overlay = document.createElement('div');
|
|
3010
|
-
overlay.id = OVERLAY_ID;
|
|
2978
|
+
overlay.id = OVERLAY_ID$1;
|
|
3011
2979
|
overlay.className = this.backdropBlur ? 'sv-overlay sv-blur' : 'sv-overlay';
|
|
3012
2980
|
overlay.setAttribute('role', 'dialog');
|
|
3013
2981
|
overlay.setAttribute('aria-modal', 'true');
|
|
3014
2982
|
overlay.setAttribute('aria-labelledby', 'sv-modal-title');
|
|
3015
2983
|
const modal = document.createElement('div');
|
|
3016
|
-
modal.id = MODAL_ID;
|
|
2984
|
+
modal.id = MODAL_ID$1;
|
|
3017
2985
|
modal.className = 'sv-modal';
|
|
3018
2986
|
const header = this.createHeader(options.branding);
|
|
3019
2987
|
this.headerElement = header;
|
|
@@ -3029,6 +2997,8 @@ class ModalContainer {
|
|
|
3029
2997
|
footerLink.target = '_blank';
|
|
3030
2998
|
footerLink.rel = 'noopener noreferrer';
|
|
3031
2999
|
footerLink.className = 'sv-footer-link';
|
|
3000
|
+
footerLink.style.color = 'inherit';
|
|
3001
|
+
footerLink.style.textDecoration = 'none';
|
|
3032
3002
|
footerLink.textContent = 'SparkVault';
|
|
3033
3003
|
footerText.appendChild(footerLink);
|
|
3034
3004
|
footer.appendChild(footerText);
|
|
@@ -4055,6 +4025,11 @@ class PasskeyPromptView {
|
|
|
4055
4025
|
const subtitle = document.createElement('p');
|
|
4056
4026
|
subtitle.className = 'sv-subtitle';
|
|
4057
4027
|
subtitle.innerHTML = `Next time, sign in instantly as<br><strong>${escapeHtml(this.props.email)}</strong>`;
|
|
4028
|
+
// Error message if registration failed
|
|
4029
|
+
const errorContainer = div();
|
|
4030
|
+
if (this.props.error) {
|
|
4031
|
+
errorContainer.appendChild(errorMessage(this.props.error));
|
|
4032
|
+
}
|
|
4058
4033
|
const benefitsContainer = document.createElement('div');
|
|
4059
4034
|
benefitsContainer.className = 'sv-passkey-prompt-benefits';
|
|
4060
4035
|
const benefitsList = document.createElement('ul');
|
|
@@ -4072,15 +4047,16 @@ class PasskeyPromptView {
|
|
|
4072
4047
|
benefitsContainer.appendChild(benefitsList);
|
|
4073
4048
|
this.addButton = document.createElement('button');
|
|
4074
4049
|
this.addButton.className = 'sv-btn sv-btn-primary';
|
|
4075
|
-
this.addButton.textContent = 'Add Passkey';
|
|
4050
|
+
this.addButton.textContent = this.props.error ? 'Try Again' : 'Add Passkey';
|
|
4076
4051
|
this.addButton.addEventListener('click', this.boundHandleAdd);
|
|
4077
4052
|
this.skipLink = document.createElement('button');
|
|
4078
4053
|
this.skipLink.className = 'sv-skip-link';
|
|
4079
|
-
this.skipLink.textContent = 'Not now';
|
|
4054
|
+
this.skipLink.textContent = this.props.error ? 'Skip for now' : 'Not now';
|
|
4080
4055
|
this.skipLink.addEventListener('click', this.boundHandleSkip);
|
|
4081
4056
|
container.appendChild(iconContainer);
|
|
4082
4057
|
container.appendChild(title);
|
|
4083
4058
|
container.appendChild(subtitle);
|
|
4059
|
+
container.appendChild(errorContainer);
|
|
4084
4060
|
container.appendChild(benefitsContainer);
|
|
4085
4061
|
container.appendChild(this.addButton);
|
|
4086
4062
|
container.appendChild(this.skipLink);
|
|
@@ -4149,7 +4125,7 @@ class SparkLinkWaitingView {
|
|
|
4149
4125
|
// Resend button
|
|
4150
4126
|
const resendContainer = div('sv-resend-container');
|
|
4151
4127
|
this.resendButton = document.createElement('button');
|
|
4152
|
-
this.resendButton.className = 'sv-
|
|
4128
|
+
this.resendButton.className = 'sv-btn sv-btn-secondary';
|
|
4153
4129
|
this.resendButton.textContent = 'Resend link';
|
|
4154
4130
|
this.resendButton.addEventListener('click', this.boundHandleResend);
|
|
4155
4131
|
resendContainer.appendChild(this.resendButton);
|
|
@@ -4382,6 +4358,10 @@ class IdentityRenderer {
|
|
|
4382
4358
|
else if (options.phone) {
|
|
4383
4359
|
this.verificationState.setIdentity(options.phone, 'phone');
|
|
4384
4360
|
}
|
|
4361
|
+
// Set auth context for OIDC/simple mode flows
|
|
4362
|
+
if (options.authContext) {
|
|
4363
|
+
this.verificationState.setAuthContext(options.authContext);
|
|
4364
|
+
}
|
|
4385
4365
|
}
|
|
4386
4366
|
// Convenience accessors for state
|
|
4387
4367
|
get recipient() {
|
|
@@ -4445,13 +4425,7 @@ class IdentityRenderer {
|
|
|
4445
4425
|
}
|
|
4446
4426
|
}
|
|
4447
4427
|
catch (error) {
|
|
4448
|
-
|
|
4449
|
-
// Show error in modal instead of just calling error callback
|
|
4450
|
-
this.setState({
|
|
4451
|
-
view: 'error',
|
|
4452
|
-
message: err.message,
|
|
4453
|
-
code: err instanceof SparkVaultError ? err.code : 'config_error',
|
|
4454
|
-
});
|
|
4428
|
+
this.handleErrorWithRecovery(error, 'config_error');
|
|
4455
4429
|
}
|
|
4456
4430
|
}
|
|
4457
4431
|
/**
|
|
@@ -4544,7 +4518,10 @@ class IdentityRenderer {
|
|
|
4544
4518
|
onStart: () => this.handlePasskeyStart(),
|
|
4545
4519
|
onBack: isFromPrompt
|
|
4546
4520
|
? () => this.handlePasskeyRegistrationSkip()
|
|
4547
|
-
:
|
|
4521
|
+
: state.mode === 'verify'
|
|
4522
|
+
// From passkey verify, enable fallback so showMethodSelect doesn't loop back
|
|
4523
|
+
? () => this.handlePasskeyFallback()
|
|
4524
|
+
: () => this.showMethodSelect(),
|
|
4548
4525
|
onFallback: passkey.hasPasskey && !passkey.isFallbackMode
|
|
4549
4526
|
? () => this.handlePasskeyFallback()
|
|
4550
4527
|
: undefined,
|
|
@@ -4555,6 +4532,7 @@ class IdentityRenderer {
|
|
|
4555
4532
|
email: state.email,
|
|
4556
4533
|
onAddPasskey: () => this.handlePasskeyPromptAdd(state.pendingResult),
|
|
4557
4534
|
onSkip: () => this.handlePasskeyPromptSkip(state.pendingResult),
|
|
4535
|
+
error: state.error,
|
|
4558
4536
|
});
|
|
4559
4537
|
case 'sparklink-waiting':
|
|
4560
4538
|
return new SparkLinkWaitingView({
|
|
@@ -4691,7 +4669,7 @@ class IdentityRenderer {
|
|
|
4691
4669
|
});
|
|
4692
4670
|
break;
|
|
4693
4671
|
}
|
|
4694
|
-
case '
|
|
4672
|
+
case 'sparklink': {
|
|
4695
4673
|
this.setState({ view: 'loading' });
|
|
4696
4674
|
const result = await this.sparkLinkHandler.send();
|
|
4697
4675
|
if (!result.success) {
|
|
@@ -4726,12 +4704,7 @@ class IdentityRenderer {
|
|
|
4726
4704
|
}
|
|
4727
4705
|
}
|
|
4728
4706
|
catch (error) {
|
|
4729
|
-
|
|
4730
|
-
this.setState({
|
|
4731
|
-
view: 'error',
|
|
4732
|
-
message: err.message,
|
|
4733
|
-
code: err instanceof SparkVaultError ? err.code : 'unknown',
|
|
4734
|
-
});
|
|
4707
|
+
this.handleErrorWithRecovery(error, 'unknown');
|
|
4735
4708
|
}
|
|
4736
4709
|
}
|
|
4737
4710
|
async handleTotpSubmit(code) {
|
|
@@ -4740,6 +4713,12 @@ class IdentityRenderer {
|
|
|
4740
4713
|
const currentMethod = this.verificationState.totp.method ?? 'email';
|
|
4741
4714
|
const result = await this.totpHandler.verify(code);
|
|
4742
4715
|
if (result.success && result.result) {
|
|
4716
|
+
// Handle redirect for OIDC/simple mode flows
|
|
4717
|
+
if (result.result.redirect) {
|
|
4718
|
+
this.close();
|
|
4719
|
+
window.location.href = result.result.redirect;
|
|
4720
|
+
return;
|
|
4721
|
+
}
|
|
4743
4722
|
// Check if we should prompt for passkey registration
|
|
4744
4723
|
if (await this.shouldShowPasskeyPrompt()) {
|
|
4745
4724
|
this.setState({
|
|
@@ -4753,7 +4732,32 @@ class IdentityRenderer {
|
|
|
4753
4732
|
this.callbacks.onSuccess(result.result);
|
|
4754
4733
|
}
|
|
4755
4734
|
else {
|
|
4756
|
-
//
|
|
4735
|
+
// Check if error is expiry - auto-resend if so
|
|
4736
|
+
const errorMsg = result.error?.toLowerCase() ?? '';
|
|
4737
|
+
if (errorMsg.includes('expired')) {
|
|
4738
|
+
// Auto-resend a new code
|
|
4739
|
+
const resendResult = await this.totpHandler.resend();
|
|
4740
|
+
if (resendResult.success) {
|
|
4741
|
+
this.setState({
|
|
4742
|
+
view: 'totp-verify',
|
|
4743
|
+
email: this.recipient,
|
|
4744
|
+
method: currentMethod,
|
|
4745
|
+
kindling: this.verificationState.totp.kindling,
|
|
4746
|
+
error: 'Code expired. We\'ve sent a new one.',
|
|
4747
|
+
});
|
|
4748
|
+
return;
|
|
4749
|
+
}
|
|
4750
|
+
// Resend failed - show error
|
|
4751
|
+
this.setState({
|
|
4752
|
+
view: 'totp-verify',
|
|
4753
|
+
email: this.recipient,
|
|
4754
|
+
method: currentMethod,
|
|
4755
|
+
kindling: this.verificationState.totp.kindling,
|
|
4756
|
+
error: resendResult.error ?? 'Code expired and failed to resend.',
|
|
4757
|
+
});
|
|
4758
|
+
return;
|
|
4759
|
+
}
|
|
4760
|
+
// Other verification failure
|
|
4757
4761
|
this.setState({
|
|
4758
4762
|
view: 'totp-verify',
|
|
4759
4763
|
email: this.recipient,
|
|
@@ -4788,6 +4792,12 @@ class IdentityRenderer {
|
|
|
4788
4792
|
? await this.passkeyHandler.register()
|
|
4789
4793
|
: await this.passkeyHandler.verify();
|
|
4790
4794
|
if (result.success && result.result) {
|
|
4795
|
+
// Handle redirect for OIDC/simple mode flows
|
|
4796
|
+
if (result.result.redirect) {
|
|
4797
|
+
this.close();
|
|
4798
|
+
window.location.href = result.result.redirect;
|
|
4799
|
+
return;
|
|
4800
|
+
}
|
|
4791
4801
|
this.close();
|
|
4792
4802
|
this.callbacks.onSuccess(result.result);
|
|
4793
4803
|
}
|
|
@@ -4827,6 +4837,12 @@ class IdentityRenderer {
|
|
|
4827
4837
|
const pendingResult = this.verificationState.passkey.pendingResult;
|
|
4828
4838
|
if (pendingResult) {
|
|
4829
4839
|
this.verificationState.setPendingResult(null);
|
|
4840
|
+
// Handle redirect for OIDC/simple mode flows
|
|
4841
|
+
if (pendingResult.redirect) {
|
|
4842
|
+
this.close();
|
|
4843
|
+
window.location.href = pendingResult.redirect;
|
|
4844
|
+
return;
|
|
4845
|
+
}
|
|
4830
4846
|
this.close();
|
|
4831
4847
|
this.callbacks.onSuccess(pendingResult);
|
|
4832
4848
|
}
|
|
@@ -4849,6 +4865,38 @@ class IdentityRenderer {
|
|
|
4849
4865
|
}
|
|
4850
4866
|
return new Error(String(error));
|
|
4851
4867
|
}
|
|
4868
|
+
/**
|
|
4869
|
+
* Check if error is a session/token expiration that should redirect to identity input.
|
|
4870
|
+
*/
|
|
4871
|
+
isSessionExpiredError(error) {
|
|
4872
|
+
const message = error.message.toLowerCase();
|
|
4873
|
+
const code = error instanceof SparkVaultError ? error.code : '';
|
|
4874
|
+
return (message.includes('expired') ||
|
|
4875
|
+
message.includes('session') ||
|
|
4876
|
+
message.includes('invalid token') ||
|
|
4877
|
+
message.includes('token invalid') ||
|
|
4878
|
+
code === 'token_expired' ||
|
|
4879
|
+
code === 'session_expired' ||
|
|
4880
|
+
code === 'kindling_expired');
|
|
4881
|
+
}
|
|
4882
|
+
/**
|
|
4883
|
+
* Handle errors - redirect to identity input for session errors, show error view otherwise.
|
|
4884
|
+
*/
|
|
4885
|
+
handleErrorWithRecovery(error, fallbackCode) {
|
|
4886
|
+
const err = this.normalizeError(error);
|
|
4887
|
+
if (this.isSessionExpiredError(err)) {
|
|
4888
|
+
// Session expired - let user start over
|
|
4889
|
+
this.showIdentityInput('Your session has expired. Please try again.');
|
|
4890
|
+
}
|
|
4891
|
+
else {
|
|
4892
|
+
// Other error - show error view
|
|
4893
|
+
this.setState({
|
|
4894
|
+
view: 'error',
|
|
4895
|
+
message: err.message,
|
|
4896
|
+
code: err instanceof SparkVaultError ? err.code : fallbackCode,
|
|
4897
|
+
});
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4852
4900
|
/**
|
|
4853
4901
|
* Check if we should show the passkey registration prompt after PIN verification.
|
|
4854
4902
|
*/
|
|
@@ -4878,21 +4926,33 @@ class IdentityRenderer {
|
|
|
4878
4926
|
// Directly trigger passkey registration
|
|
4879
4927
|
const result = await this.passkeyHandler.register();
|
|
4880
4928
|
if (result.success && result.result) {
|
|
4929
|
+
// Handle redirect for OIDC/simple mode flows
|
|
4930
|
+
if (result.result.redirect) {
|
|
4931
|
+
this.close();
|
|
4932
|
+
window.location.href = result.result.redirect;
|
|
4933
|
+
return;
|
|
4934
|
+
}
|
|
4881
4935
|
// Passkey created successfully - use the new token
|
|
4882
4936
|
this.close();
|
|
4883
4937
|
this.callbacks.onSuccess(result.result);
|
|
4884
4938
|
}
|
|
4885
4939
|
else if (result.errorType === 'cancelled') {
|
|
4886
|
-
// User cancelled browser dialog -
|
|
4887
|
-
this.
|
|
4888
|
-
|
|
4889
|
-
|
|
4940
|
+
// User cancelled browser dialog - go back to prompt so they can skip or try again
|
|
4941
|
+
this.setState({
|
|
4942
|
+
view: 'passkey-prompt',
|
|
4943
|
+
email: this.recipient,
|
|
4944
|
+
pendingResult,
|
|
4945
|
+
});
|
|
4890
4946
|
}
|
|
4891
4947
|
else {
|
|
4892
|
-
// Registration failed -
|
|
4893
|
-
|
|
4894
|
-
this.
|
|
4895
|
-
|
|
4948
|
+
// Registration failed - show error with retry option
|
|
4949
|
+
const errorMessage = result.error || 'Failed to create passkey. Please try again.';
|
|
4950
|
+
this.setState({
|
|
4951
|
+
view: 'passkey-prompt',
|
|
4952
|
+
email: this.recipient,
|
|
4953
|
+
pendingResult,
|
|
4954
|
+
error: errorMessage,
|
|
4955
|
+
});
|
|
4896
4956
|
}
|
|
4897
4957
|
}
|
|
4898
4958
|
/**
|
|
@@ -4901,6 +4961,12 @@ class IdentityRenderer {
|
|
|
4901
4961
|
handlePasskeyPromptSkip(pendingResult) {
|
|
4902
4962
|
// Set 30-day cookie to suppress future prompts
|
|
4903
4963
|
this.verificationState.dismissPasskeyPrompt();
|
|
4964
|
+
// Handle redirect for OIDC/simple mode flows
|
|
4965
|
+
if (pendingResult.redirect) {
|
|
4966
|
+
this.close();
|
|
4967
|
+
window.location.href = pendingResult.redirect;
|
|
4968
|
+
return;
|
|
4969
|
+
}
|
|
4904
4970
|
// Complete the verification
|
|
4905
4971
|
this.close();
|
|
4906
4972
|
this.callbacks.onSuccess(pendingResult);
|
|
@@ -4920,7 +4986,7 @@ class IdentityRenderer {
|
|
|
4920
4986
|
return;
|
|
4921
4987
|
if (event.data.type !== 'sparklink_verified')
|
|
4922
4988
|
return;
|
|
4923
|
-
const { token, identity, identityType } = event.data;
|
|
4989
|
+
const { token, identity, identityType, redirect } = event.data;
|
|
4924
4990
|
// Validate required fields
|
|
4925
4991
|
if (!token || !identity)
|
|
4926
4992
|
return;
|
|
@@ -4928,6 +4994,7 @@ class IdentityRenderer {
|
|
|
4928
4994
|
token,
|
|
4929
4995
|
identity,
|
|
4930
4996
|
identityType: identityType || 'email',
|
|
4997
|
+
redirect,
|
|
4931
4998
|
});
|
|
4932
4999
|
};
|
|
4933
5000
|
window.addEventListener('message', this.messageListener);
|
|
@@ -4940,6 +5007,7 @@ class IdentityRenderer {
|
|
|
4940
5007
|
token: status.token,
|
|
4941
5008
|
identity: status.identity,
|
|
4942
5009
|
identityType: status.identityType || 'email',
|
|
5010
|
+
redirect: status.redirect,
|
|
4943
5011
|
});
|
|
4944
5012
|
}
|
|
4945
5013
|
}, 2000);
|
|
@@ -4949,6 +5017,12 @@ class IdentityRenderer {
|
|
|
4949
5017
|
*/
|
|
4950
5018
|
async handleSparkLinkVerified(result) {
|
|
4951
5019
|
this.stopSparkLinkPolling();
|
|
5020
|
+
// Handle redirect for OIDC/simple mode flows
|
|
5021
|
+
if (result.redirect) {
|
|
5022
|
+
this.close();
|
|
5023
|
+
window.location.href = result.redirect;
|
|
5024
|
+
return;
|
|
5025
|
+
}
|
|
4952
5026
|
// Check if we should prompt for passkey registration
|
|
4953
5027
|
if (await this.shouldShowPasskeyPrompt()) {
|
|
4954
5028
|
this.setState({
|
|
@@ -5023,7 +5097,7 @@ class IdentityRenderer {
|
|
|
5023
5097
|
*
|
|
5024
5098
|
* Implements the Container interface for use with IdentityRenderer.
|
|
5025
5099
|
*/
|
|
5026
|
-
const DEFAULT_OPTIONS = {
|
|
5100
|
+
const DEFAULT_OPTIONS$1 = {
|
|
5027
5101
|
showHeader: true,
|
|
5028
5102
|
showCloseButton: true,
|
|
5029
5103
|
showFooter: true,
|
|
@@ -5039,7 +5113,7 @@ class InlineContainer {
|
|
|
5039
5113
|
this.closeBtnClickHandler = null;
|
|
5040
5114
|
this.effectiveTheme = 'light';
|
|
5041
5115
|
this.targetElement = targetElement;
|
|
5042
|
-
this.containerOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
5116
|
+
this.containerOptions = { ...DEFAULT_OPTIONS$1, ...options };
|
|
5043
5117
|
}
|
|
5044
5118
|
/**
|
|
5045
5119
|
* Create the inline container with loading state.
|
|
@@ -5087,6 +5161,8 @@ class InlineContainer {
|
|
|
5087
5161
|
footerLink.target = '_blank';
|
|
5088
5162
|
footerLink.rel = 'noopener noreferrer';
|
|
5089
5163
|
footerLink.className = 'sv-footer-link';
|
|
5164
|
+
footerLink.style.color = 'inherit';
|
|
5165
|
+
footerLink.style.textDecoration = 'none';
|
|
5090
5166
|
footerLink.textContent = 'SparkVault';
|
|
5091
5167
|
footerText.appendChild(footerLink);
|
|
5092
5168
|
this.footer.appendChild(footerText);
|
|
@@ -5196,11 +5272,40 @@ class InlineContainer {
|
|
|
5196
5272
|
}
|
|
5197
5273
|
}
|
|
5198
5274
|
|
|
5275
|
+
/**
|
|
5276
|
+
* DOM Utilities
|
|
5277
|
+
*
|
|
5278
|
+
* Shared utilities for DOM operations.
|
|
5279
|
+
*/
|
|
5280
|
+
/**
|
|
5281
|
+
* Execute a callback when the DOM is ready.
|
|
5282
|
+
* If DOM is already ready, executes immediately.
|
|
5283
|
+
* Otherwise, waits for DOMContentLoaded.
|
|
5284
|
+
*/
|
|
5285
|
+
function onDomReady(callback) {
|
|
5286
|
+
if (document.readyState === 'loading') {
|
|
5287
|
+
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
|
5288
|
+
}
|
|
5289
|
+
else {
|
|
5290
|
+
callback();
|
|
5291
|
+
}
|
|
5292
|
+
}
|
|
5293
|
+
|
|
5294
|
+
/* global Element */
|
|
5199
5295
|
/**
|
|
5200
5296
|
* Identity Module
|
|
5201
5297
|
*
|
|
5202
|
-
* Provides identity verification through
|
|
5203
|
-
* Supports passkey, TOTP,
|
|
5298
|
+
* Provides identity verification through dialog or inline UI.
|
|
5299
|
+
* Supports passkey, TOTP, SparkLink, and social authentication.
|
|
5300
|
+
*
|
|
5301
|
+
* @example Dialog mode (immediate)
|
|
5302
|
+
* const result = await sv.identity.verify();
|
|
5303
|
+
*
|
|
5304
|
+
* @example Dialog mode (attached to clicks)
|
|
5305
|
+
* sv.identity.attach('.login-btn');
|
|
5306
|
+
*
|
|
5307
|
+
* @example Inline mode
|
|
5308
|
+
* const result = await sv.identity.verify({ target: '#auth-container' });
|
|
5204
5309
|
*/
|
|
5205
5310
|
function isTokenClaims(payload) {
|
|
5206
5311
|
if (typeof payload !== 'object' || payload === null) {
|
|
@@ -5220,30 +5325,46 @@ function isTokenClaims(payload) {
|
|
|
5220
5325
|
class IdentityModule {
|
|
5221
5326
|
constructor(config) {
|
|
5222
5327
|
this.renderer = null;
|
|
5328
|
+
this.attachedElements = new Map();
|
|
5223
5329
|
this.config = config;
|
|
5224
5330
|
this.api = new IdentityApi(config);
|
|
5225
|
-
// Preload config in background for instant
|
|
5331
|
+
// Preload config in background for instant dialog opening
|
|
5226
5332
|
if (config.preloadConfig) {
|
|
5227
5333
|
this.api.preloadConfig();
|
|
5228
5334
|
}
|
|
5229
5335
|
}
|
|
5230
5336
|
/**
|
|
5231
|
-
*
|
|
5232
|
-
* Returns when user successfully verifies their identity.
|
|
5337
|
+
* Verify user identity.
|
|
5233
5338
|
*
|
|
5234
|
-
*
|
|
5235
|
-
*
|
|
5339
|
+
* - Without `target`: Opens a dialog (modal)
|
|
5340
|
+
* - With `target`: Renders inline into the specified element
|
|
5341
|
+
*
|
|
5342
|
+
* @example Dialog mode
|
|
5343
|
+
* const result = await sv.identity.verify();
|
|
5344
|
+
* console.log(result.token, result.identity);
|
|
5345
|
+
*
|
|
5346
|
+
* @example Inline mode
|
|
5347
|
+
* const result = await sv.identity.verify({
|
|
5348
|
+
* target: '#auth-container',
|
|
5236
5349
|
* email: 'user@example.com'
|
|
5237
5350
|
* });
|
|
5238
|
-
* console.log(result.token, result.identity, result.identityType);
|
|
5239
5351
|
*/
|
|
5240
|
-
async
|
|
5352
|
+
async verify(options = {}) {
|
|
5241
5353
|
if (this.renderer) {
|
|
5242
5354
|
this.renderer.close();
|
|
5243
5355
|
}
|
|
5356
|
+
const isInline = !!options.target;
|
|
5357
|
+
const container = isInline
|
|
5358
|
+
? this.createInlineContainer(options.target)
|
|
5359
|
+
: new ModalContainer();
|
|
5360
|
+
// Merge global config defaults with per-call options
|
|
5361
|
+
const mergedOptions = {
|
|
5362
|
+
...options,
|
|
5363
|
+
// Use global backdropBlur unless explicitly overridden per-call
|
|
5364
|
+
backdropBlur: options.backdropBlur ?? this.config.backdropBlur,
|
|
5365
|
+
};
|
|
5244
5366
|
return new Promise((resolve, reject) => {
|
|
5245
|
-
|
|
5246
|
-
this.renderer = new IdentityRenderer(modal, this.api, options, {
|
|
5367
|
+
this.renderer = new IdentityRenderer(container, this.api, mergedOptions, {
|
|
5247
5368
|
onSuccess: (result) => {
|
|
5248
5369
|
options.onSuccess?.(result);
|
|
5249
5370
|
resolve(result);
|
|
@@ -5265,58 +5386,81 @@ class IdentityModule {
|
|
|
5265
5386
|
});
|
|
5266
5387
|
}
|
|
5267
5388
|
/**
|
|
5268
|
-
*
|
|
5269
|
-
*
|
|
5270
|
-
* directly into the specified element.
|
|
5389
|
+
* Attach identity verification to element clicks.
|
|
5390
|
+
* When any matching element is clicked, opens the verification dialog.
|
|
5271
5391
|
*
|
|
5272
|
-
* @
|
|
5273
|
-
*
|
|
5274
|
-
*
|
|
5275
|
-
* target: document.getElementById('auth-container'),
|
|
5276
|
-
* email: 'user@example.com'
|
|
5277
|
-
* });
|
|
5392
|
+
* @param selector - CSS selector for elements to attach to
|
|
5393
|
+
* @param options - Verification options and callbacks
|
|
5394
|
+
* @returns Cleanup function to remove event listeners
|
|
5278
5395
|
*
|
|
5279
5396
|
* @example
|
|
5280
|
-
*
|
|
5281
|
-
*
|
|
5282
|
-
* target: dialogContentElement,
|
|
5283
|
-
* showHeader: false,
|
|
5284
|
-
* showFooter: false
|
|
5397
|
+
* const cleanup = sv.identity.attach('.login-btn', {
|
|
5398
|
+
* onSuccess: (result) => console.log('Verified:', result.identity)
|
|
5285
5399
|
* });
|
|
5400
|
+
*
|
|
5401
|
+
* // Later, remove listeners
|
|
5402
|
+
* cleanup();
|
|
5286
5403
|
*/
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
reject(error);
|
|
5313
|
-
},
|
|
5404
|
+
attach(selector, options = {}) {
|
|
5405
|
+
let observer = null;
|
|
5406
|
+
const handleClick = async (event) => {
|
|
5407
|
+
event.preventDefault();
|
|
5408
|
+
try {
|
|
5409
|
+
await this.verify({
|
|
5410
|
+
email: options.email,
|
|
5411
|
+
phone: options.phone,
|
|
5412
|
+
authContext: options.authContext,
|
|
5413
|
+
onSuccess: options.onSuccess,
|
|
5414
|
+
onError: options.onError,
|
|
5415
|
+
onCancel: options.onCancel,
|
|
5416
|
+
});
|
|
5417
|
+
}
|
|
5418
|
+
catch {
|
|
5419
|
+
// Error already handled by callbacks or will use server redirect
|
|
5420
|
+
}
|
|
5421
|
+
};
|
|
5422
|
+
const attachToElements = () => {
|
|
5423
|
+
const elements = document.querySelectorAll(selector);
|
|
5424
|
+
elements.forEach((element) => {
|
|
5425
|
+
element.addEventListener('click', handleClick);
|
|
5426
|
+
this.attachedElements.set(element, () => {
|
|
5427
|
+
element.removeEventListener('click', handleClick);
|
|
5428
|
+
});
|
|
5314
5429
|
});
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5430
|
+
// Watch for dynamically added elements
|
|
5431
|
+
observer = new MutationObserver((mutations) => {
|
|
5432
|
+
mutations.forEach((mutation) => {
|
|
5433
|
+
mutation.addedNodes.forEach((node) => {
|
|
5434
|
+
if (node instanceof Element) {
|
|
5435
|
+
if (node.matches(selector)) {
|
|
5436
|
+
node.addEventListener('click', handleClick);
|
|
5437
|
+
this.attachedElements.set(node, () => {
|
|
5438
|
+
node.removeEventListener('click', handleClick);
|
|
5439
|
+
});
|
|
5440
|
+
}
|
|
5441
|
+
// Check descendants
|
|
5442
|
+
node.querySelectorAll(selector).forEach((el) => {
|
|
5443
|
+
if (!this.attachedElements.has(el)) {
|
|
5444
|
+
el.addEventListener('click', handleClick);
|
|
5445
|
+
this.attachedElements.set(el, () => {
|
|
5446
|
+
el.removeEventListener('click', handleClick);
|
|
5447
|
+
});
|
|
5448
|
+
}
|
|
5449
|
+
});
|
|
5450
|
+
}
|
|
5451
|
+
});
|
|
5452
|
+
});
|
|
5318
5453
|
});
|
|
5319
|
-
|
|
5454
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
5455
|
+
};
|
|
5456
|
+
// Defer attachment until DOM is ready
|
|
5457
|
+
onDomReady(attachToElements);
|
|
5458
|
+
// Return cleanup function
|
|
5459
|
+
return () => {
|
|
5460
|
+
observer?.disconnect();
|
|
5461
|
+
this.attachedElements.forEach((cleanup) => cleanup());
|
|
5462
|
+
this.attachedElements.clear();
|
|
5463
|
+
};
|
|
5320
5464
|
}
|
|
5321
5465
|
/**
|
|
5322
5466
|
* Verify and decode an identity token.
|
|
@@ -5324,10 +5468,6 @@ class IdentityModule {
|
|
|
5324
5468
|
*
|
|
5325
5469
|
* Note: For production use, verify the Ed25519 signature server-side
|
|
5326
5470
|
* using the JWKS endpoint.
|
|
5327
|
-
*
|
|
5328
|
-
* @example
|
|
5329
|
-
* const claims = await sv.identity.verifyToken(token);
|
|
5330
|
-
* console.log(claims.identity, claims.identity_type, claims.method);
|
|
5331
5471
|
*/
|
|
5332
5472
|
async verifyToken(token) {
|
|
5333
5473
|
if (!token || typeof token !== 'string') {
|
|
@@ -5338,7 +5478,6 @@ class IdentityModule {
|
|
|
5338
5478
|
throw new ValidationError('Invalid token format');
|
|
5339
5479
|
}
|
|
5340
5480
|
const [, payloadB64] = parts;
|
|
5341
|
-
// Use centralized base64url utility (CLAUDE.md DRY)
|
|
5342
5481
|
const payload = JSON.parse(base64urlToString(payloadB64));
|
|
5343
5482
|
if (!isTokenClaims(payload)) {
|
|
5344
5483
|
throw new ValidationError('Invalid token payload structure');
|
|
@@ -5354,7 +5493,7 @@ class IdentityModule {
|
|
|
5354
5493
|
return payload;
|
|
5355
5494
|
}
|
|
5356
5495
|
/**
|
|
5357
|
-
* Close the identity
|
|
5496
|
+
* Close the identity dialog/inline UI if open.
|
|
5358
5497
|
*/
|
|
5359
5498
|
close() {
|
|
5360
5499
|
if (this.renderer) {
|
|
@@ -5363,97 +5502,2855 @@ class IdentityModule {
|
|
|
5363
5502
|
}
|
|
5364
5503
|
}
|
|
5365
5504
|
/**
|
|
5366
|
-
*
|
|
5505
|
+
* Resolve target to an HTMLElement.
|
|
5367
5506
|
*/
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5507
|
+
createInlineContainer(target) {
|
|
5508
|
+
let element;
|
|
5509
|
+
if (typeof target === 'string') {
|
|
5510
|
+
const found = document.querySelector(target);
|
|
5511
|
+
if (!found || !(found instanceof HTMLElement)) {
|
|
5512
|
+
throw new ValidationError(`Target selector "${target}" did not match any element`);
|
|
5513
|
+
}
|
|
5514
|
+
element = found;
|
|
5515
|
+
}
|
|
5516
|
+
else if (target instanceof HTMLElement) {
|
|
5517
|
+
element = target;
|
|
5518
|
+
}
|
|
5519
|
+
else {
|
|
5520
|
+
throw new ValidationError('Target must be a CSS selector string or HTMLElement');
|
|
5521
|
+
}
|
|
5522
|
+
return new InlineContainer(element);
|
|
5523
|
+
}
|
|
5524
|
+
// Deprecated methods for backwards compatibility
|
|
5525
|
+
/**
|
|
5526
|
+
* @deprecated Use `verify()` instead. Will be removed in v2.0.
|
|
5527
|
+
*/
|
|
5528
|
+
async pop(options = {}) {
|
|
5529
|
+
return this.verify(options);
|
|
5530
|
+
}
|
|
5531
|
+
/**
|
|
5532
|
+
* @deprecated Use `verify({ target })` instead. Will be removed in v2.0.
|
|
5533
|
+
*/
|
|
5534
|
+
async render(options) {
|
|
5535
|
+
return this.verify(options);
|
|
5536
|
+
}
|
|
5537
|
+
}
|
|
5538
|
+
|
|
5539
|
+
/**
|
|
5540
|
+
* Upload API
|
|
5541
|
+
*
|
|
5542
|
+
* API client for vault upload operations.
|
|
5543
|
+
*/
|
|
5544
|
+
/** Default request timeout in milliseconds (30 seconds) */
|
|
5545
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
5546
|
+
/**
|
|
5547
|
+
* Error class for upload API errors.
|
|
5548
|
+
*/
|
|
5549
|
+
class UploadApiError extends SparkVaultError {
|
|
5550
|
+
constructor(message, code, httpStatus) {
|
|
5551
|
+
super(message, code, httpStatus);
|
|
5552
|
+
this.httpStatus = httpStatus;
|
|
5553
|
+
this.name = 'UploadApiError';
|
|
5554
|
+
}
|
|
5555
|
+
}
|
|
5556
|
+
/**
|
|
5557
|
+
* Runtime validation for VaultUploadInfoResponse.
|
|
5558
|
+
* Validates required fields exist and have correct types.
|
|
5559
|
+
*/
|
|
5560
|
+
function isVaultUploadInfoResponse(data) {
|
|
5561
|
+
if (typeof data !== 'object' || data === null)
|
|
5562
|
+
return false;
|
|
5563
|
+
const d = data;
|
|
5564
|
+
return (typeof d.vault_id === 'string' &&
|
|
5565
|
+
typeof d.vault_name === 'string' &&
|
|
5566
|
+
typeof d.max_size_bytes === 'number' &&
|
|
5567
|
+
typeof d.branding === 'object' &&
|
|
5568
|
+
d.branding !== null &&
|
|
5569
|
+
typeof d.branding.organization_name === 'string' &&
|
|
5570
|
+
typeof d.encryption === 'object' &&
|
|
5571
|
+
d.encryption !== null &&
|
|
5572
|
+
typeof d.encryption.algorithm === 'string' &&
|
|
5573
|
+
(d.forge_status === 'active' || d.forge_status === 'inactive'));
|
|
5574
|
+
}
|
|
5575
|
+
/**
|
|
5576
|
+
* Runtime validation for InitiateUploadResponse.
|
|
5577
|
+
*/
|
|
5578
|
+
function isInitiateUploadResponse(data) {
|
|
5579
|
+
if (typeof data !== 'object' || data === null)
|
|
5580
|
+
return false;
|
|
5581
|
+
const d = data;
|
|
5582
|
+
return typeof d.forge_url === 'string' && typeof d.ingot_id === 'string';
|
|
5583
|
+
}
|
|
5584
|
+
class UploadApi {
|
|
5585
|
+
constructor(config) {
|
|
5586
|
+
this.config = config;
|
|
5587
|
+
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
5588
|
+
}
|
|
5589
|
+
/**
|
|
5590
|
+
* Get vault upload info (public endpoint).
|
|
5591
|
+
*/
|
|
5592
|
+
async getVaultUploadInfo(vaultId) {
|
|
5593
|
+
const url = `${this.config.apiBaseUrl}/v1/files/in/${vaultId}`;
|
|
5594
|
+
const response = await this.request(url, { method: 'GET' });
|
|
5595
|
+
if (!isVaultUploadInfoResponse(response.data)) {
|
|
5596
|
+
throw new UploadApiError('Invalid vault upload info response from server', 'invalid_response', response.status);
|
|
5597
|
+
}
|
|
5598
|
+
const data = response.data;
|
|
5599
|
+
return {
|
|
5600
|
+
vaultId: data.vault_id,
|
|
5601
|
+
vaultName: data.vault_name,
|
|
5602
|
+
maxSizeBytes: data.max_size_bytes,
|
|
5603
|
+
branding: {
|
|
5604
|
+
organizationName: data.branding.organization_name,
|
|
5605
|
+
logoLightUrl: data.branding.logo_light_url,
|
|
5606
|
+
logoDarkUrl: data.branding.logo_dark_url,
|
|
5607
|
+
},
|
|
5608
|
+
encryption: {
|
|
5609
|
+
algorithm: data.encryption.algorithm,
|
|
5610
|
+
keyDerivation: data.encryption.key_derivation,
|
|
5611
|
+
postQuantum: data.encryption.post_quantum,
|
|
5612
|
+
},
|
|
5613
|
+
forgeStatus: data.forge_status,
|
|
5614
|
+
};
|
|
5615
|
+
}
|
|
5616
|
+
/**
|
|
5617
|
+
* Initiate an upload to get Forge URL and ingot ID.
|
|
5618
|
+
*/
|
|
5619
|
+
async initiateUpload(vaultId, filename, sizeBytes, contentType) {
|
|
5620
|
+
const url = `${this.config.apiBaseUrl}/v1/files/in/${vaultId}/upload`;
|
|
5621
|
+
const response = await this.request(url, {
|
|
5622
|
+
method: 'POST',
|
|
5623
|
+
body: JSON.stringify({
|
|
5624
|
+
filename,
|
|
5625
|
+
size_bytes: sizeBytes,
|
|
5626
|
+
content_type: contentType,
|
|
5627
|
+
}),
|
|
5628
|
+
});
|
|
5629
|
+
if (!isInitiateUploadResponse(response.data)) {
|
|
5630
|
+
throw new UploadApiError('Invalid initiate upload response from server', 'invalid_response', response.status);
|
|
5631
|
+
}
|
|
5632
|
+
return {
|
|
5633
|
+
forgeUrl: response.data.forge_url,
|
|
5634
|
+
ingotId: response.data.ingot_id,
|
|
5635
|
+
};
|
|
5636
|
+
}
|
|
5637
|
+
/**
|
|
5638
|
+
* Internal request method with timeout handling and error context.
|
|
5639
|
+
*/
|
|
5640
|
+
async request(url, options) {
|
|
5641
|
+
const controller = new AbortController();
|
|
5642
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
5643
|
+
try {
|
|
5644
|
+
const response = await fetch(url, {
|
|
5645
|
+
method: options.method,
|
|
5646
|
+
headers: {
|
|
5647
|
+
'Content-Type': 'application/json',
|
|
5648
|
+
'Accept': 'application/json',
|
|
5649
|
+
},
|
|
5650
|
+
body: options.body,
|
|
5651
|
+
signal: controller.signal,
|
|
5652
|
+
});
|
|
5653
|
+
const json = await response.json();
|
|
5654
|
+
if (!response.ok) {
|
|
5655
|
+
// Safely extract error fields with runtime type checking
|
|
5656
|
+
const errorData = typeof json === 'object' && json !== null ? json : {};
|
|
5657
|
+
const errorObj = typeof errorData.error === 'object' && errorData.error !== null
|
|
5658
|
+
? errorData.error
|
|
5659
|
+
: errorData;
|
|
5660
|
+
const message = (typeof errorObj.message === 'string' ? errorObj.message : null) ??
|
|
5661
|
+
(typeof errorObj.error === 'string' ? errorObj.error : null) ??
|
|
5662
|
+
'Request failed';
|
|
5663
|
+
const code = typeof errorObj.code === 'string' ? errorObj.code : 'api_error';
|
|
5664
|
+
throw new UploadApiError(message, code, response.status);
|
|
5665
|
+
}
|
|
5666
|
+
// API responses are wrapped in { data: ..., meta: ... }
|
|
5667
|
+
const data = typeof json === 'object' && json !== null && 'data' in json
|
|
5668
|
+
? json.data
|
|
5669
|
+
: json;
|
|
5670
|
+
return { data, status: response.status };
|
|
5671
|
+
}
|
|
5672
|
+
catch (error) {
|
|
5673
|
+
// Re-throw SparkVault errors as-is
|
|
5674
|
+
if (error instanceof SparkVaultError) {
|
|
5675
|
+
throw error;
|
|
5676
|
+
}
|
|
5677
|
+
// Convert AbortError to TimeoutError
|
|
5678
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
5679
|
+
throw new TimeoutError(`Request timed out after ${this.timeoutMs}ms`);
|
|
5680
|
+
}
|
|
5681
|
+
// Network errors with context
|
|
5682
|
+
if (error instanceof TypeError) {
|
|
5683
|
+
throw new NetworkError(`Network request failed: ${error.message}`, { url, method: options.method });
|
|
5684
|
+
}
|
|
5685
|
+
// Unknown errors - preserve message
|
|
5686
|
+
throw new NetworkError(error instanceof Error
|
|
5687
|
+
? `Request failed: ${error.message}`
|
|
5688
|
+
: 'Request failed: Unknown error');
|
|
5689
|
+
}
|
|
5690
|
+
finally {
|
|
5691
|
+
clearTimeout(timeoutId);
|
|
5692
|
+
}
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5695
|
+
|
|
5696
|
+
/**
|
|
5697
|
+
* Upload Widget Styles
|
|
5698
|
+
*
|
|
5699
|
+
* Enterprise-grade styling for the vault upload widget.
|
|
5700
|
+
* Follows the same design principles as the identity modal.
|
|
5701
|
+
*/
|
|
5702
|
+
const STYLE_ID = 'sparkvault-upload-styles';
|
|
5703
|
+
/**
|
|
5704
|
+
* Inject upload widget styles into the document head.
|
|
5705
|
+
*/
|
|
5706
|
+
function injectUploadStyles(options) {
|
|
5707
|
+
if (document.getElementById(STYLE_ID)) {
|
|
5708
|
+
return;
|
|
5709
|
+
}
|
|
5710
|
+
const style = document.createElement('style');
|
|
5711
|
+
style.id = STYLE_ID;
|
|
5712
|
+
style.textContent = getUploadStyles(options);
|
|
5713
|
+
document.head.appendChild(style);
|
|
5714
|
+
}
|
|
5715
|
+
function getUploadStyles(options) {
|
|
5716
|
+
const { backdropBlur } = options;
|
|
5717
|
+
const enableBlur = backdropBlur !== false;
|
|
5718
|
+
// Design tokens - using light theme for upload widget (matches PublicUploader)
|
|
5719
|
+
const tokens = {
|
|
5720
|
+
// Colors
|
|
5721
|
+
primary: '#4F46E5',
|
|
5722
|
+
primaryHover: '#4338CA',
|
|
5723
|
+
primaryLight: 'rgba(79, 70, 229, 0.08)',
|
|
5724
|
+
error: '#DC2626',
|
|
5725
|
+
errorLight: '#FEF2F2',
|
|
5726
|
+
errorBorder: '#FECACA',
|
|
5727
|
+
success: '#059669',
|
|
5728
|
+
// Backgrounds
|
|
5729
|
+
bg: '#FFFFFF',
|
|
5730
|
+
bgSubtle: '#FAFAFA',
|
|
5731
|
+
bgHover: '#F5F5F5',
|
|
5732
|
+
bgDark: '#0F0F0F',
|
|
5733
|
+
bgDarkSubtle: '#171717',
|
|
5734
|
+
// Borders
|
|
5735
|
+
border: '#E5E5E5',
|
|
5736
|
+
borderHover: '#D4D4D4',
|
|
5737
|
+
borderDark: '#262626',
|
|
5738
|
+
// Text
|
|
5739
|
+
textPrimary: '#0A0A0A',
|
|
5740
|
+
textSecondary: '#525252',
|
|
5741
|
+
textMuted: '#737373',
|
|
5742
|
+
textOnPrimary: '#FFFFFF',
|
|
5743
|
+
textOnDark: '#FAFAFA',
|
|
5744
|
+
textMutedOnDark: '#A3A3A3',
|
|
5745
|
+
// Shadows
|
|
5746
|
+
shadowSm: '0 1px 2px rgba(0, 0, 0, 0.04)',
|
|
5747
|
+
shadowLg: '0 8px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04)',
|
|
5748
|
+
};
|
|
5749
|
+
return `
|
|
5750
|
+
/* ========================================
|
|
5751
|
+
OVERLAY & MODAL CONTAINER
|
|
5752
|
+
======================================== */
|
|
5753
|
+
|
|
5754
|
+
.svu-overlay {
|
|
5755
|
+
position: fixed;
|
|
5756
|
+
inset: 0;
|
|
5757
|
+
background: rgba(0, 0, 0, 0.5);
|
|
5758
|
+
display: flex;
|
|
5759
|
+
align-items: center;
|
|
5760
|
+
justify-content: center;
|
|
5761
|
+
z-index: 999999;
|
|
5762
|
+
padding: 16px;
|
|
5763
|
+
box-sizing: border-box;
|
|
5764
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
5765
|
+
-webkit-font-smoothing: antialiased;
|
|
5766
|
+
-moz-osx-font-smoothing: grayscale;
|
|
5767
|
+
}
|
|
5768
|
+
|
|
5769
|
+
.svu-overlay.svu-blur {
|
|
5770
|
+
backdrop-filter: blur(${enableBlur ? '8px' : '0'});
|
|
5771
|
+
-webkit-backdrop-filter: blur(${enableBlur ? '8px' : '0'});
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
.svu-modal {
|
|
5775
|
+
background: ${tokens.bg};
|
|
5776
|
+
border-radius: 16px;
|
|
5777
|
+
box-shadow: ${tokens.shadowLg};
|
|
5778
|
+
width: 100%;
|
|
5779
|
+
max-width: 480px;
|
|
5780
|
+
max-height: calc(100vh - 32px);
|
|
5781
|
+
display: flex;
|
|
5782
|
+
flex-direction: column;
|
|
5783
|
+
color: ${tokens.textPrimary};
|
|
5784
|
+
overflow: hidden;
|
|
5785
|
+
}
|
|
5786
|
+
|
|
5787
|
+
.svu-modal.svu-with-sidebar {
|
|
5788
|
+
max-width: 900px;
|
|
5789
|
+
flex-direction: row;
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5792
|
+
.svu-modal-main {
|
|
5793
|
+
flex: 1;
|
|
5794
|
+
display: flex;
|
|
5795
|
+
flex-direction: column;
|
|
5796
|
+
min-width: 0;
|
|
5797
|
+
}
|
|
5798
|
+
|
|
5799
|
+
/* Dark card variant for uploading/ceremony states */
|
|
5800
|
+
.svu-modal.svu-dark {
|
|
5801
|
+
background: ${tokens.bgDark};
|
|
5802
|
+
color: ${tokens.textOnDark};
|
|
5803
|
+
}
|
|
5804
|
+
|
|
5805
|
+
/* ========================================
|
|
5806
|
+
HEADER
|
|
5807
|
+
======================================== */
|
|
5808
|
+
|
|
5809
|
+
.svu-header {
|
|
5810
|
+
display: flex;
|
|
5811
|
+
align-items: center;
|
|
5812
|
+
justify-content: space-between;
|
|
5813
|
+
padding: 14px 20px;
|
|
5814
|
+
border-bottom: 1px solid ${tokens.border};
|
|
5815
|
+
background: ${tokens.bg};
|
|
5816
|
+
flex-shrink: 0;
|
|
5817
|
+
}
|
|
5818
|
+
|
|
5819
|
+
.svu-dark .svu-header {
|
|
5820
|
+
border-bottom-color: ${tokens.borderDark};
|
|
5821
|
+
background: ${tokens.bgDark};
|
|
5822
|
+
}
|
|
5823
|
+
|
|
5824
|
+
.svu-header-title {
|
|
5825
|
+
display: flex;
|
|
5826
|
+
align-items: center;
|
|
5827
|
+
gap: 12px;
|
|
5828
|
+
min-width: 0;
|
|
5829
|
+
}
|
|
5830
|
+
|
|
5831
|
+
.svu-logo {
|
|
5832
|
+
height: 28px;
|
|
5833
|
+
width: auto;
|
|
5834
|
+
max-width: 140px;
|
|
5835
|
+
object-fit: contain;
|
|
5836
|
+
flex-shrink: 0;
|
|
5837
|
+
}
|
|
5838
|
+
|
|
5839
|
+
.svu-company-name {
|
|
5840
|
+
font-size: 15px;
|
|
5841
|
+
font-weight: 600;
|
|
5842
|
+
letter-spacing: -0.01em;
|
|
5843
|
+
margin: 0;
|
|
5844
|
+
white-space: nowrap;
|
|
5845
|
+
overflow: hidden;
|
|
5846
|
+
text-overflow: ellipsis;
|
|
5847
|
+
}
|
|
5848
|
+
|
|
5849
|
+
.svu-close-btn {
|
|
5850
|
+
flex-shrink: 0;
|
|
5851
|
+
width: 32px;
|
|
5852
|
+
height: 32px;
|
|
5853
|
+
display: flex;
|
|
5854
|
+
align-items: center;
|
|
5855
|
+
justify-content: center;
|
|
5856
|
+
background: transparent;
|
|
5857
|
+
border: none;
|
|
5858
|
+
border-radius: 8px;
|
|
5859
|
+
cursor: pointer;
|
|
5860
|
+
color: ${tokens.textMuted};
|
|
5861
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
5862
|
+
}
|
|
5863
|
+
|
|
5864
|
+
.svu-close-btn:hover {
|
|
5865
|
+
background: ${tokens.bgHover};
|
|
5866
|
+
color: ${tokens.textPrimary};
|
|
5867
|
+
}
|
|
5868
|
+
|
|
5869
|
+
.svu-dark .svu-close-btn {
|
|
5870
|
+
color: ${tokens.textMutedOnDark};
|
|
5871
|
+
}
|
|
5872
|
+
|
|
5873
|
+
.svu-dark .svu-close-btn:hover {
|
|
5874
|
+
background: ${tokens.bgDarkSubtle};
|
|
5875
|
+
color: ${tokens.textOnDark};
|
|
5876
|
+
}
|
|
5877
|
+
|
|
5878
|
+
/* ========================================
|
|
5879
|
+
BODY
|
|
5880
|
+
======================================== */
|
|
5881
|
+
|
|
5882
|
+
.svu-body {
|
|
5883
|
+
padding: 24px 28px;
|
|
5884
|
+
overflow-y: auto;
|
|
5885
|
+
flex: 1;
|
|
5886
|
+
}
|
|
5887
|
+
|
|
5888
|
+
/* ========================================
|
|
5889
|
+
FOOTER
|
|
5890
|
+
======================================== */
|
|
5891
|
+
|
|
5892
|
+
.svu-footer {
|
|
5893
|
+
padding: 10px 20px;
|
|
5894
|
+
border-top: 1px solid ${tokens.border};
|
|
5895
|
+
text-align: center;
|
|
5896
|
+
background: ${tokens.bgSubtle};
|
|
5897
|
+
flex-shrink: 0;
|
|
5898
|
+
}
|
|
5899
|
+
|
|
5900
|
+
.svu-dark .svu-footer {
|
|
5901
|
+
border-top-color: ${tokens.borderDark};
|
|
5902
|
+
background: ${tokens.bgDarkSubtle};
|
|
5903
|
+
}
|
|
5904
|
+
|
|
5905
|
+
.svu-footer-text {
|
|
5906
|
+
font-size: 10px;
|
|
5907
|
+
color: ${tokens.textMuted};
|
|
5908
|
+
letter-spacing: 0.02em;
|
|
5909
|
+
}
|
|
5910
|
+
|
|
5911
|
+
.svu-dark .svu-footer-text {
|
|
5912
|
+
color: ${tokens.textMutedOnDark};
|
|
5913
|
+
}
|
|
5914
|
+
|
|
5915
|
+
.svu-footer-link {
|
|
5916
|
+
color: ${tokens.textSecondary};
|
|
5917
|
+
text-decoration: none;
|
|
5918
|
+
font-weight: 500;
|
|
5919
|
+
transition: color 0.15s ease;
|
|
5920
|
+
}
|
|
5921
|
+
|
|
5922
|
+
.svu-footer-link:hover {
|
|
5923
|
+
color: ${tokens.primary};
|
|
5924
|
+
}
|
|
5925
|
+
|
|
5926
|
+
/* ========================================
|
|
5927
|
+
LOADING STATE
|
|
5928
|
+
======================================== */
|
|
5929
|
+
|
|
5930
|
+
.svu-loading {
|
|
5931
|
+
display: flex;
|
|
5932
|
+
flex-direction: column;
|
|
5933
|
+
align-items: center;
|
|
5934
|
+
justify-content: center;
|
|
5935
|
+
padding: 48px 24px;
|
|
5936
|
+
gap: 16px;
|
|
5937
|
+
}
|
|
5938
|
+
|
|
5939
|
+
.svu-spinner {
|
|
5940
|
+
width: 28px;
|
|
5941
|
+
height: 28px;
|
|
5942
|
+
border: 2.5px solid ${tokens.border};
|
|
5943
|
+
border-top-color: ${tokens.primary};
|
|
5944
|
+
border-radius: 50%;
|
|
5945
|
+
animation: svu-spin 0.7s linear infinite;
|
|
5946
|
+
}
|
|
5947
|
+
|
|
5948
|
+
.svu-loading-text {
|
|
5949
|
+
font-size: 14px;
|
|
5950
|
+
color: ${tokens.textSecondary};
|
|
5951
|
+
margin: 0;
|
|
5952
|
+
}
|
|
5953
|
+
|
|
5954
|
+
@keyframes svu-spin {
|
|
5955
|
+
to { transform: rotate(360deg); }
|
|
5956
|
+
}
|
|
5957
|
+
|
|
5958
|
+
/* ========================================
|
|
5959
|
+
FORM VIEW
|
|
5960
|
+
======================================== */
|
|
5961
|
+
|
|
5962
|
+
.svu-form-view {
|
|
5963
|
+
display: flex;
|
|
5964
|
+
flex-direction: column;
|
|
5965
|
+
align-items: center;
|
|
5966
|
+
}
|
|
5967
|
+
|
|
5968
|
+
.svu-title {
|
|
5969
|
+
font-size: 18px;
|
|
5970
|
+
font-weight: 600;
|
|
5971
|
+
letter-spacing: -0.02em;
|
|
5972
|
+
line-height: 1.3;
|
|
5973
|
+
margin: 0 0 6px 0;
|
|
5974
|
+
text-align: center;
|
|
5975
|
+
color: ${tokens.textPrimary};
|
|
5976
|
+
}
|
|
5977
|
+
|
|
5978
|
+
.svu-dark .svu-title {
|
|
5979
|
+
color: ${tokens.textOnDark};
|
|
5980
|
+
}
|
|
5981
|
+
|
|
5982
|
+
.svu-subtitle {
|
|
5983
|
+
font-size: 14px;
|
|
5984
|
+
font-weight: 400;
|
|
5985
|
+
line-height: 1.5;
|
|
5986
|
+
color: ${tokens.textSecondary};
|
|
5987
|
+
margin: 0 0 20px 0;
|
|
5988
|
+
text-align: center;
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5991
|
+
.svu-subtitle strong {
|
|
5992
|
+
color: ${tokens.textPrimary};
|
|
5993
|
+
font-weight: 500;
|
|
5994
|
+
}
|
|
5995
|
+
|
|
5996
|
+
/* Drop Zone */
|
|
5997
|
+
.svu-drop-zone {
|
|
5998
|
+
width: 100%;
|
|
5999
|
+
border: 2px dashed ${tokens.border};
|
|
6000
|
+
border-radius: 12px;
|
|
6001
|
+
padding: 32px 24px;
|
|
6002
|
+
text-align: center;
|
|
6003
|
+
cursor: pointer;
|
|
6004
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
6005
|
+
margin-bottom: 16px;
|
|
6006
|
+
}
|
|
6007
|
+
|
|
6008
|
+
.svu-drop-zone:hover {
|
|
6009
|
+
border-color: ${tokens.borderHover};
|
|
6010
|
+
background: ${tokens.bgSubtle};
|
|
6011
|
+
}
|
|
6012
|
+
|
|
6013
|
+
.svu-drop-zone.svu-dragging {
|
|
6014
|
+
border-color: ${tokens.primary};
|
|
6015
|
+
background: ${tokens.primaryLight};
|
|
6016
|
+
}
|
|
6017
|
+
|
|
6018
|
+
.svu-drop-zone.svu-has-file {
|
|
6019
|
+
border-style: solid;
|
|
6020
|
+
border-color: ${tokens.primary};
|
|
6021
|
+
background: ${tokens.primaryLight};
|
|
6022
|
+
cursor: default;
|
|
6023
|
+
}
|
|
6024
|
+
|
|
6025
|
+
.svu-drop-icon {
|
|
6026
|
+
color: ${tokens.textMuted};
|
|
6027
|
+
margin-bottom: 12px;
|
|
6028
|
+
}
|
|
6029
|
+
|
|
6030
|
+
.svu-drop-text {
|
|
6031
|
+
font-size: 14px;
|
|
6032
|
+
font-weight: 500;
|
|
6033
|
+
color: ${tokens.textPrimary};
|
|
6034
|
+
margin: 0 0 4px 0;
|
|
6035
|
+
}
|
|
6036
|
+
|
|
6037
|
+
.svu-drop-subtext {
|
|
6038
|
+
font-size: 13px;
|
|
6039
|
+
color: ${tokens.textSecondary};
|
|
6040
|
+
margin: 0 0 8px 0;
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
.svu-drop-hint {
|
|
6044
|
+
font-size: 12px;
|
|
6045
|
+
color: ${tokens.textMuted};
|
|
6046
|
+
margin: 0;
|
|
6047
|
+
}
|
|
6048
|
+
|
|
6049
|
+
/* Selected File */
|
|
6050
|
+
.svu-selected-file {
|
|
6051
|
+
display: flex;
|
|
6052
|
+
align-items: center;
|
|
6053
|
+
gap: 12px;
|
|
6054
|
+
width: 100%;
|
|
6055
|
+
}
|
|
6056
|
+
|
|
6057
|
+
.svu-file-icon {
|
|
6058
|
+
color: ${tokens.primary};
|
|
6059
|
+
flex-shrink: 0;
|
|
6060
|
+
}
|
|
6061
|
+
|
|
6062
|
+
.svu-file-info {
|
|
6063
|
+
flex: 1;
|
|
6064
|
+
min-width: 0;
|
|
6065
|
+
text-align: left;
|
|
6066
|
+
}
|
|
6067
|
+
|
|
6068
|
+
.svu-file-name {
|
|
6069
|
+
display: block;
|
|
6070
|
+
font-size: 14px;
|
|
6071
|
+
font-weight: 500;
|
|
6072
|
+
color: ${tokens.textPrimary};
|
|
6073
|
+
white-space: nowrap;
|
|
6074
|
+
overflow: hidden;
|
|
6075
|
+
text-overflow: ellipsis;
|
|
6076
|
+
}
|
|
6077
|
+
|
|
6078
|
+
.svu-file-size {
|
|
6079
|
+
display: block;
|
|
6080
|
+
font-size: 12px;
|
|
6081
|
+
color: ${tokens.textSecondary};
|
|
6082
|
+
}
|
|
6083
|
+
|
|
6084
|
+
.svu-remove-file {
|
|
6085
|
+
flex-shrink: 0;
|
|
6086
|
+
width: 24px;
|
|
6087
|
+
height: 24px;
|
|
6088
|
+
display: flex;
|
|
6089
|
+
align-items: center;
|
|
6090
|
+
justify-content: center;
|
|
6091
|
+
background: transparent;
|
|
6092
|
+
border: none;
|
|
6093
|
+
border-radius: 50%;
|
|
6094
|
+
cursor: pointer;
|
|
6095
|
+
font-size: 18px;
|
|
6096
|
+
color: ${tokens.textMuted};
|
|
6097
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
6098
|
+
}
|
|
6099
|
+
|
|
6100
|
+
.svu-remove-file:hover {
|
|
6101
|
+
background: ${tokens.bgHover};
|
|
6102
|
+
color: ${tokens.error};
|
|
6103
|
+
}
|
|
6104
|
+
|
|
6105
|
+
/* Max Size */
|
|
6106
|
+
.svu-max-size {
|
|
6107
|
+
font-size: 12px;
|
|
6108
|
+
color: ${tokens.textMuted};
|
|
6109
|
+
margin: 0 0 16px 0;
|
|
6110
|
+
}
|
|
6111
|
+
|
|
6112
|
+
/* Upload Button */
|
|
6113
|
+
.svu-btn {
|
|
6114
|
+
display: inline-flex;
|
|
6115
|
+
align-items: center;
|
|
6116
|
+
justify-content: center;
|
|
6117
|
+
gap: 8px;
|
|
6118
|
+
width: 100%;
|
|
6119
|
+
padding: 12px 20px;
|
|
6120
|
+
font-size: 14px;
|
|
6121
|
+
font-weight: 500;
|
|
6122
|
+
letter-spacing: -0.005em;
|
|
6123
|
+
line-height: 1.4;
|
|
6124
|
+
border: none;
|
|
6125
|
+
border-radius: 8px;
|
|
6126
|
+
cursor: pointer;
|
|
6127
|
+
transition: background 0.15s ease, transform 0.1s ease;
|
|
6128
|
+
box-sizing: border-box;
|
|
6129
|
+
}
|
|
6130
|
+
|
|
6131
|
+
.svu-btn:disabled {
|
|
6132
|
+
opacity: 0.5;
|
|
6133
|
+
cursor: not-allowed;
|
|
6134
|
+
}
|
|
6135
|
+
|
|
6136
|
+
.svu-btn:active:not(:disabled) {
|
|
6137
|
+
transform: scale(0.98);
|
|
6138
|
+
}
|
|
6139
|
+
|
|
6140
|
+
.svu-btn-primary {
|
|
6141
|
+
background: ${tokens.primary};
|
|
6142
|
+
color: ${tokens.textOnPrimary};
|
|
6143
|
+
box-shadow: ${tokens.shadowSm};
|
|
6144
|
+
}
|
|
6145
|
+
|
|
6146
|
+
.svu-btn-primary:hover:not(:disabled) {
|
|
6147
|
+
background: ${tokens.primaryHover};
|
|
6148
|
+
}
|
|
6149
|
+
|
|
6150
|
+
.svu-btn-secondary {
|
|
6151
|
+
background: ${tokens.bgSubtle};
|
|
6152
|
+
color: ${tokens.textPrimary};
|
|
6153
|
+
border: 1px solid ${tokens.border};
|
|
6154
|
+
}
|
|
6155
|
+
|
|
6156
|
+
.svu-btn-secondary:hover:not(:disabled) {
|
|
6157
|
+
background: ${tokens.bgHover};
|
|
6158
|
+
}
|
|
6159
|
+
|
|
6160
|
+
/* Error Alert */
|
|
6161
|
+
.svu-error-alert {
|
|
6162
|
+
background: ${tokens.errorLight};
|
|
6163
|
+
border: 1px solid ${tokens.errorBorder};
|
|
6164
|
+
color: ${tokens.error};
|
|
6165
|
+
padding: 10px 12px;
|
|
6166
|
+
border-radius: 8px;
|
|
6167
|
+
font-size: 13px;
|
|
6168
|
+
font-weight: 450;
|
|
6169
|
+
margin-bottom: 16px;
|
|
6170
|
+
width: 100%;
|
|
6171
|
+
}
|
|
6172
|
+
|
|
6173
|
+
/* Metadata Grid */
|
|
6174
|
+
.svu-metadata {
|
|
6175
|
+
width: 100%;
|
|
6176
|
+
margin-top: 20px;
|
|
6177
|
+
padding-top: 20px;
|
|
6178
|
+
border-top: 1px solid ${tokens.border};
|
|
6179
|
+
}
|
|
6180
|
+
|
|
6181
|
+
.svu-metadata h4 {
|
|
6182
|
+
font-size: 12px;
|
|
6183
|
+
font-weight: 600;
|
|
6184
|
+
color: ${tokens.textSecondary};
|
|
6185
|
+
text-transform: uppercase;
|
|
6186
|
+
letter-spacing: 0.05em;
|
|
6187
|
+
margin: 0 0 12px 0;
|
|
6188
|
+
}
|
|
6189
|
+
|
|
6190
|
+
.svu-metadata-grid {
|
|
6191
|
+
display: grid;
|
|
6192
|
+
grid-template-columns: 1fr 1fr;
|
|
6193
|
+
gap: 8px;
|
|
6194
|
+
}
|
|
6195
|
+
|
|
6196
|
+
.svu-metadata-item {
|
|
6197
|
+
font-size: 12px;
|
|
6198
|
+
}
|
|
6199
|
+
|
|
6200
|
+
.svu-metadata-item.svu-full-width {
|
|
6201
|
+
grid-column: span 2;
|
|
6202
|
+
}
|
|
6203
|
+
|
|
6204
|
+
.svu-metadata-label {
|
|
6205
|
+
display: block;
|
|
6206
|
+
color: ${tokens.textMuted};
|
|
6207
|
+
margin-bottom: 2px;
|
|
6208
|
+
}
|
|
6209
|
+
|
|
6210
|
+
.svu-metadata-value {
|
|
6211
|
+
display: block;
|
|
6212
|
+
color: ${tokens.textPrimary};
|
|
6213
|
+
font-weight: 500;
|
|
6214
|
+
}
|
|
6215
|
+
|
|
6216
|
+
.svu-metadata-value.svu-status-active {
|
|
6217
|
+
color: ${tokens.success};
|
|
6218
|
+
}
|
|
6219
|
+
|
|
6220
|
+
/* ========================================
|
|
6221
|
+
UPLOADING VIEW
|
|
6222
|
+
======================================== */
|
|
6223
|
+
|
|
6224
|
+
.svu-uploading-view {
|
|
6225
|
+
display: flex;
|
|
6226
|
+
flex-direction: column;
|
|
6227
|
+
align-items: center;
|
|
6228
|
+
}
|
|
6229
|
+
|
|
6230
|
+
.svu-stages {
|
|
6231
|
+
display: flex;
|
|
6232
|
+
align-items: center;
|
|
6233
|
+
justify-content: center;
|
|
6234
|
+
gap: 12px;
|
|
6235
|
+
margin: 24px 0;
|
|
6236
|
+
width: 100%;
|
|
6237
|
+
}
|
|
6238
|
+
|
|
6239
|
+
.svu-stage {
|
|
6240
|
+
display: flex;
|
|
6241
|
+
flex-direction: column;
|
|
6242
|
+
align-items: center;
|
|
6243
|
+
gap: 8px;
|
|
6244
|
+
opacity: 0.5;
|
|
6245
|
+
transition: opacity 0.3s ease;
|
|
6246
|
+
}
|
|
6247
|
+
|
|
6248
|
+
.svu-stage.svu-active {
|
|
6249
|
+
opacity: 1;
|
|
6250
|
+
}
|
|
6251
|
+
|
|
6252
|
+
.svu-stage span {
|
|
6253
|
+
font-size: 11px;
|
|
6254
|
+
color: ${tokens.textMutedOnDark};
|
|
6255
|
+
}
|
|
6256
|
+
|
|
6257
|
+
.svu-stage-sub {
|
|
6258
|
+
font-size: 10px !important;
|
|
6259
|
+
color: ${tokens.primary} !important;
|
|
6260
|
+
}
|
|
6261
|
+
|
|
6262
|
+
.svu-stage-arrow {
|
|
6263
|
+
width: 24px;
|
|
6264
|
+
height: 2px;
|
|
6265
|
+
background: ${tokens.borderDark};
|
|
6266
|
+
position: relative;
|
|
6267
|
+
}
|
|
6268
|
+
|
|
6269
|
+
.svu-stage-arrow.svu-active::after {
|
|
6270
|
+
content: '';
|
|
6271
|
+
position: absolute;
|
|
6272
|
+
left: 0;
|
|
6273
|
+
top: 0;
|
|
6274
|
+
height: 100%;
|
|
6275
|
+
background: ${tokens.primary};
|
|
6276
|
+
animation: svu-arrow-flow 1s ease-in-out infinite;
|
|
6277
|
+
}
|
|
6278
|
+
|
|
6279
|
+
@keyframes svu-arrow-flow {
|
|
6280
|
+
0% { width: 0; }
|
|
6281
|
+
50% { width: 100%; }
|
|
6282
|
+
100% { width: 100%; }
|
|
6283
|
+
}
|
|
6284
|
+
|
|
6285
|
+
/* Progress Bar */
|
|
6286
|
+
.svu-progress {
|
|
6287
|
+
width: 100%;
|
|
6288
|
+
margin: 20px 0;
|
|
6289
|
+
}
|
|
6290
|
+
|
|
6291
|
+
.svu-progress-bar {
|
|
6292
|
+
width: 100%;
|
|
6293
|
+
height: 6px;
|
|
6294
|
+
background: ${tokens.borderDark};
|
|
6295
|
+
border-radius: 3px;
|
|
6296
|
+
overflow: hidden;
|
|
6297
|
+
}
|
|
6298
|
+
|
|
6299
|
+
.svu-progress-fill {
|
|
6300
|
+
height: 100%;
|
|
6301
|
+
background: ${tokens.primary};
|
|
6302
|
+
border-radius: 3px;
|
|
6303
|
+
transition: width 0.3s ease;
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6306
|
+
.svu-progress-text {
|
|
6307
|
+
display: flex;
|
|
6308
|
+
justify-content: space-between;
|
|
6309
|
+
margin-top: 8px;
|
|
6310
|
+
font-size: 12px;
|
|
6311
|
+
color: ${tokens.textMutedOnDark};
|
|
6312
|
+
}
|
|
6313
|
+
|
|
6314
|
+
/* Forging Info */
|
|
6315
|
+
.svu-forging-info {
|
|
6316
|
+
width: 100%;
|
|
6317
|
+
margin-top: 20px;
|
|
6318
|
+
padding: 16px;
|
|
6319
|
+
background: ${tokens.bgDarkSubtle};
|
|
6320
|
+
border-radius: 8px;
|
|
6321
|
+
}
|
|
6322
|
+
|
|
6323
|
+
.svu-forging-line {
|
|
6324
|
+
font-size: 12px;
|
|
6325
|
+
color: ${tokens.textMutedOnDark};
|
|
6326
|
+
margin: 0 0 8px 0;
|
|
6327
|
+
}
|
|
6328
|
+
|
|
6329
|
+
.svu-forging-line:last-child {
|
|
6330
|
+
margin-bottom: 0;
|
|
6331
|
+
}
|
|
6332
|
+
|
|
6333
|
+
.svu-mono {
|
|
6334
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, monospace;
|
|
6335
|
+
color: ${tokens.textOnDark};
|
|
6336
|
+
}
|
|
6337
|
+
|
|
6338
|
+
/* ========================================
|
|
6339
|
+
CEREMONY VIEW
|
|
6340
|
+
======================================== */
|
|
6341
|
+
|
|
6342
|
+
.svu-ceremony-view {
|
|
6343
|
+
display: flex;
|
|
6344
|
+
flex-direction: column;
|
|
6345
|
+
align-items: center;
|
|
6346
|
+
}
|
|
6347
|
+
|
|
6348
|
+
.svu-ceremony-steps {
|
|
6349
|
+
width: 100%;
|
|
6350
|
+
margin: 20px 0;
|
|
6351
|
+
}
|
|
6352
|
+
|
|
6353
|
+
.svu-ceremony-step {
|
|
6354
|
+
display: flex;
|
|
6355
|
+
align-items: center;
|
|
6356
|
+
gap: 12px;
|
|
6357
|
+
padding: 10px 0;
|
|
6358
|
+
opacity: 0.4;
|
|
6359
|
+
transition: opacity 0.3s ease;
|
|
6360
|
+
}
|
|
6361
|
+
|
|
6362
|
+
.svu-ceremony-step.svu-active {
|
|
6363
|
+
opacity: 1;
|
|
6364
|
+
}
|
|
6365
|
+
|
|
6366
|
+
.svu-ceremony-step.svu-complete {
|
|
6367
|
+
opacity: 0.7;
|
|
6368
|
+
}
|
|
6369
|
+
|
|
6370
|
+
.svu-step-icon {
|
|
6371
|
+
width: 20px;
|
|
6372
|
+
height: 20px;
|
|
6373
|
+
display: flex;
|
|
6374
|
+
align-items: center;
|
|
6375
|
+
justify-content: center;
|
|
6376
|
+
color: ${tokens.primary};
|
|
6377
|
+
}
|
|
6378
|
+
|
|
6379
|
+
.svu-step-text {
|
|
6380
|
+
font-size: 13px;
|
|
6381
|
+
color: ${tokens.textOnDark};
|
|
6382
|
+
}
|
|
6383
|
+
|
|
6384
|
+
/* Spinning loader */
|
|
6385
|
+
.svu-step-icon .svu-spin {
|
|
6386
|
+
animation: svu-spin 0.7s linear infinite;
|
|
6387
|
+
}
|
|
6388
|
+
|
|
6389
|
+
/* ========================================
|
|
6390
|
+
COMPLETE VIEW
|
|
6391
|
+
======================================== */
|
|
6392
|
+
|
|
6393
|
+
.svu-complete-view {
|
|
6394
|
+
display: flex;
|
|
6395
|
+
flex-direction: column;
|
|
6396
|
+
align-items: center;
|
|
6397
|
+
animation: svu-scale-in 0.3s ease;
|
|
6398
|
+
}
|
|
6399
|
+
|
|
6400
|
+
@keyframes svu-scale-in {
|
|
6401
|
+
from {
|
|
6402
|
+
opacity: 0;
|
|
6403
|
+
transform: scale(0.95);
|
|
6404
|
+
}
|
|
6405
|
+
to {
|
|
6406
|
+
opacity: 1;
|
|
6407
|
+
transform: scale(1);
|
|
6408
|
+
}
|
|
6409
|
+
}
|
|
6410
|
+
|
|
6411
|
+
.svu-success-icon {
|
|
6412
|
+
color: ${tokens.success};
|
|
6413
|
+
margin-bottom: 16px;
|
|
6414
|
+
}
|
|
6415
|
+
|
|
6416
|
+
.svu-success-title {
|
|
6417
|
+
font-size: 20px;
|
|
6418
|
+
font-weight: 600;
|
|
6419
|
+
color: ${tokens.textPrimary};
|
|
6420
|
+
margin: 0 0 24px 0;
|
|
6421
|
+
text-align: center;
|
|
6422
|
+
}
|
|
6423
|
+
|
|
6424
|
+
.svu-success-details {
|
|
6425
|
+
width: 100%;
|
|
6426
|
+
background: ${tokens.bgSubtle};
|
|
6427
|
+
border-radius: 8px;
|
|
6428
|
+
padding: 16px;
|
|
6429
|
+
margin-bottom: 16px;
|
|
6430
|
+
}
|
|
6431
|
+
|
|
6432
|
+
.svu-success-row {
|
|
6433
|
+
display: flex;
|
|
6434
|
+
justify-content: space-between;
|
|
6435
|
+
align-items: center;
|
|
6436
|
+
padding: 8px 0;
|
|
6437
|
+
font-size: 13px;
|
|
6438
|
+
border-bottom: 1px solid ${tokens.border};
|
|
6439
|
+
}
|
|
6440
|
+
|
|
6441
|
+
.svu-success-row:last-child {
|
|
6442
|
+
border-bottom: none;
|
|
6443
|
+
}
|
|
6444
|
+
|
|
6445
|
+
.svu-success-label {
|
|
6446
|
+
color: ${tokens.textSecondary};
|
|
6447
|
+
}
|
|
6448
|
+
|
|
6449
|
+
.svu-success-value {
|
|
6450
|
+
color: ${tokens.textPrimary};
|
|
6451
|
+
font-weight: 500;
|
|
6452
|
+
display: flex;
|
|
6453
|
+
align-items: center;
|
|
6454
|
+
gap: 8px;
|
|
6455
|
+
}
|
|
6456
|
+
|
|
6457
|
+
.svu-copy-btn {
|
|
6458
|
+
width: 24px;
|
|
6459
|
+
height: 24px;
|
|
6460
|
+
display: flex;
|
|
6461
|
+
align-items: center;
|
|
6462
|
+
justify-content: center;
|
|
6463
|
+
background: transparent;
|
|
6464
|
+
border: none;
|
|
6465
|
+
border-radius: 4px;
|
|
6466
|
+
cursor: pointer;
|
|
6467
|
+
color: ${tokens.textMuted};
|
|
6468
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
6469
|
+
}
|
|
6470
|
+
|
|
6471
|
+
.svu-copy-btn:hover {
|
|
6472
|
+
background: ${tokens.bgHover};
|
|
6473
|
+
color: ${tokens.primary};
|
|
6474
|
+
}
|
|
6475
|
+
|
|
6476
|
+
.svu-success-guidance {
|
|
6477
|
+
font-size: 13px;
|
|
6478
|
+
color: ${tokens.textSecondary};
|
|
6479
|
+
text-align: center;
|
|
6480
|
+
margin: 0 0 20px 0;
|
|
6481
|
+
line-height: 1.5;
|
|
6482
|
+
}
|
|
6483
|
+
|
|
6484
|
+
/* ========================================
|
|
6485
|
+
ERROR VIEW
|
|
6486
|
+
======================================== */
|
|
6487
|
+
|
|
6488
|
+
.svu-error-view {
|
|
6489
|
+
display: flex;
|
|
6490
|
+
flex-direction: column;
|
|
6491
|
+
align-items: center;
|
|
6492
|
+
text-align: center;
|
|
6493
|
+
}
|
|
6494
|
+
|
|
6495
|
+
.svu-error-icon {
|
|
6496
|
+
color: ${tokens.error};
|
|
6497
|
+
margin-bottom: 16px;
|
|
6498
|
+
}
|
|
6499
|
+
|
|
6500
|
+
.svu-error-title {
|
|
6501
|
+
font-size: 18px;
|
|
6502
|
+
font-weight: 600;
|
|
6503
|
+
color: ${tokens.textPrimary};
|
|
6504
|
+
margin: 0 0 8px 0;
|
|
6505
|
+
}
|
|
6506
|
+
|
|
6507
|
+
.svu-error-message {
|
|
6508
|
+
font-size: 14px;
|
|
6509
|
+
color: ${tokens.textSecondary};
|
|
6510
|
+
margin: 0 0 16px 0;
|
|
6511
|
+
line-height: 1.5;
|
|
6512
|
+
}
|
|
6513
|
+
|
|
6514
|
+
.svu-error-code {
|
|
6515
|
+
display: inline-flex;
|
|
6516
|
+
align-items: center;
|
|
6517
|
+
gap: 6px;
|
|
6518
|
+
padding: 6px 10px;
|
|
6519
|
+
background: ${tokens.errorLight};
|
|
6520
|
+
border: 1px solid ${tokens.errorBorder};
|
|
6521
|
+
border-radius: 6px;
|
|
6522
|
+
margin-bottom: 20px;
|
|
6523
|
+
}
|
|
6524
|
+
|
|
6525
|
+
.svu-error-code-label {
|
|
6526
|
+
font-size: 10px;
|
|
6527
|
+
font-weight: 600;
|
|
6528
|
+
color: #991B1B;
|
|
6529
|
+
text-transform: uppercase;
|
|
6530
|
+
letter-spacing: 0.05em;
|
|
6531
|
+
}
|
|
6532
|
+
|
|
6533
|
+
.svu-error-code-value {
|
|
6534
|
+
font-size: 11px;
|
|
6535
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, monospace;
|
|
6536
|
+
color: ${tokens.error};
|
|
6537
|
+
font-weight: 500;
|
|
6538
|
+
}
|
|
6539
|
+
|
|
6540
|
+
/* ========================================
|
|
6541
|
+
SECURITY SIDEBAR
|
|
6542
|
+
======================================== */
|
|
6543
|
+
|
|
6544
|
+
.svu-sidebar {
|
|
6545
|
+
width: 320px;
|
|
6546
|
+
background: ${tokens.bgDark};
|
|
6547
|
+
border-left: 1px solid ${tokens.borderDark};
|
|
6548
|
+
color: ${tokens.textOnDark};
|
|
6549
|
+
padding: 20px;
|
|
6550
|
+
overflow-y: auto;
|
|
6551
|
+
flex-shrink: 0;
|
|
6552
|
+
}
|
|
6553
|
+
|
|
6554
|
+
.svu-sidebar-toggle {
|
|
6555
|
+
display: flex;
|
|
6556
|
+
align-items: center;
|
|
6557
|
+
gap: 4px;
|
|
6558
|
+
background: transparent;
|
|
6559
|
+
border: none;
|
|
6560
|
+
color: ${tokens.textMutedOnDark};
|
|
6561
|
+
font-size: 12px;
|
|
6562
|
+
cursor: pointer;
|
|
6563
|
+
padding: 0;
|
|
6564
|
+
margin-bottom: 16px;
|
|
6565
|
+
}
|
|
6566
|
+
|
|
6567
|
+
.svu-sidebar-toggle:hover {
|
|
6568
|
+
color: ${tokens.textOnDark};
|
|
6569
|
+
}
|
|
6570
|
+
|
|
6571
|
+
.svu-sidebar-logo {
|
|
6572
|
+
margin-bottom: 20px;
|
|
6573
|
+
}
|
|
6574
|
+
|
|
6575
|
+
.svu-sidebar-logo img {
|
|
6576
|
+
height: 24px;
|
|
6577
|
+
width: auto;
|
|
6578
|
+
}
|
|
6579
|
+
|
|
6580
|
+
.svu-sidebar-intro h3 {
|
|
6581
|
+
font-size: 14px;
|
|
6582
|
+
font-weight: 600;
|
|
6583
|
+
margin: 0 0 8px 0;
|
|
6584
|
+
}
|
|
6585
|
+
|
|
6586
|
+
.svu-sidebar-intro p {
|
|
6587
|
+
font-size: 12px;
|
|
6588
|
+
color: ${tokens.textMutedOnDark};
|
|
6589
|
+
line-height: 1.5;
|
|
6590
|
+
margin: 0 0 20px 0;
|
|
6591
|
+
}
|
|
6592
|
+
|
|
6593
|
+
.svu-sidebar-security h4 {
|
|
6594
|
+
display: flex;
|
|
6595
|
+
align-items: center;
|
|
6596
|
+
gap: 8px;
|
|
6597
|
+
font-size: 12px;
|
|
6598
|
+
font-weight: 600;
|
|
6599
|
+
margin: 0 0 12px 0;
|
|
6600
|
+
}
|
|
6601
|
+
|
|
6602
|
+
.svu-security-intro {
|
|
6603
|
+
font-size: 11px;
|
|
6604
|
+
color: ${tokens.textMutedOnDark};
|
|
6605
|
+
line-height: 1.5;
|
|
6606
|
+
margin: 0 0 16px 0;
|
|
6607
|
+
}
|
|
6608
|
+
|
|
6609
|
+
.svu-security-intro strong {
|
|
6610
|
+
color: ${tokens.textOnDark};
|
|
6611
|
+
}
|
|
6612
|
+
|
|
6613
|
+
.svu-key-chain {
|
|
6614
|
+
display: flex;
|
|
6615
|
+
flex-direction: column;
|
|
6616
|
+
gap: 8px;
|
|
6617
|
+
margin-bottom: 16px;
|
|
6618
|
+
}
|
|
6619
|
+
|
|
6620
|
+
.svu-key-item {
|
|
6621
|
+
display: flex;
|
|
6622
|
+
gap: 12px;
|
|
6623
|
+
padding: 10px;
|
|
6624
|
+
background: ${tokens.bgDarkSubtle};
|
|
6625
|
+
border-radius: 6px;
|
|
6626
|
+
}
|
|
6627
|
+
|
|
6628
|
+
.svu-key-number {
|
|
6629
|
+
width: 20px;
|
|
6630
|
+
height: 20px;
|
|
6631
|
+
background: ${tokens.primary};
|
|
6632
|
+
color: ${tokens.textOnPrimary};
|
|
6633
|
+
border-radius: 50%;
|
|
6634
|
+
font-size: 11px;
|
|
6635
|
+
font-weight: 600;
|
|
6636
|
+
display: flex;
|
|
6637
|
+
align-items: center;
|
|
6638
|
+
justify-content: center;
|
|
6639
|
+
flex-shrink: 0;
|
|
6640
|
+
}
|
|
6641
|
+
|
|
6642
|
+
.svu-key-info {
|
|
6643
|
+
flex: 1;
|
|
6644
|
+
}
|
|
6645
|
+
|
|
6646
|
+
.svu-key-name {
|
|
6647
|
+
display: block;
|
|
6648
|
+
font-size: 11px;
|
|
6649
|
+
font-weight: 600;
|
|
6650
|
+
color: ${tokens.textOnDark};
|
|
6651
|
+
margin-bottom: 2px;
|
|
6652
|
+
}
|
|
6653
|
+
|
|
6654
|
+
.svu-key-algo {
|
|
6655
|
+
display: block;
|
|
6656
|
+
font-size: 10px;
|
|
6657
|
+
color: ${tokens.primary};
|
|
6658
|
+
margin-bottom: 4px;
|
|
6659
|
+
}
|
|
6660
|
+
|
|
6661
|
+
.svu-key-desc {
|
|
6662
|
+
display: block;
|
|
6663
|
+
font-size: 10px;
|
|
6664
|
+
color: ${tokens.textMutedOnDark};
|
|
6665
|
+
line-height: 1.4;
|
|
6666
|
+
}
|
|
6667
|
+
|
|
6668
|
+
.svu-key-connector {
|
|
6669
|
+
display: flex;
|
|
6670
|
+
justify-content: center;
|
|
6671
|
+
color: ${tokens.textMutedOnDark};
|
|
6672
|
+
}
|
|
6673
|
+
|
|
6674
|
+
.svu-encryption-result {
|
|
6675
|
+
display: flex;
|
|
6676
|
+
gap: 8px;
|
|
6677
|
+
font-size: 10px;
|
|
6678
|
+
color: ${tokens.textMutedOnDark};
|
|
6679
|
+
line-height: 1.5;
|
|
6680
|
+
padding: 10px;
|
|
6681
|
+
background: ${tokens.bgDarkSubtle};
|
|
6682
|
+
border-radius: 6px;
|
|
6683
|
+
margin-bottom: 16px;
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
.svu-encryption-result svg {
|
|
6687
|
+
color: ${tokens.success};
|
|
6688
|
+
flex-shrink: 0;
|
|
6689
|
+
}
|
|
6690
|
+
|
|
6691
|
+
.svu-encryption-result strong {
|
|
6692
|
+
color: ${tokens.textOnDark};
|
|
6693
|
+
}
|
|
6694
|
+
|
|
6695
|
+
.svu-security-badges {
|
|
6696
|
+
display: flex;
|
|
6697
|
+
flex-wrap: wrap;
|
|
6698
|
+
gap: 6px;
|
|
6699
|
+
}
|
|
6700
|
+
|
|
6701
|
+
.svu-tls-badge {
|
|
6702
|
+
font-size: 9px;
|
|
6703
|
+
font-weight: 500;
|
|
6704
|
+
padding: 4px 8px;
|
|
6705
|
+
background: ${tokens.bgDarkSubtle};
|
|
6706
|
+
border-radius: 4px;
|
|
6707
|
+
color: ${tokens.textMutedOnDark};
|
|
6708
|
+
}
|
|
6709
|
+
|
|
6710
|
+
/* ========================================
|
|
6711
|
+
INLINE CONTAINER
|
|
6712
|
+
======================================== */
|
|
6713
|
+
|
|
6714
|
+
.svu-inline-container {
|
|
6715
|
+
display: flex;
|
|
6716
|
+
flex-direction: column;
|
|
6717
|
+
width: 100%;
|
|
6718
|
+
height: 100%;
|
|
6719
|
+
box-sizing: border-box;
|
|
6720
|
+
background: ${tokens.bg};
|
|
6721
|
+
color: ${tokens.textPrimary};
|
|
6722
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
6723
|
+
-webkit-font-smoothing: antialiased;
|
|
6724
|
+
-moz-osx-font-smoothing: grayscale;
|
|
6725
|
+
border-radius: 12px;
|
|
6726
|
+
border: 1px solid ${tokens.border};
|
|
6727
|
+
overflow: hidden;
|
|
6728
|
+
}
|
|
6729
|
+
|
|
6730
|
+
.svu-inline-container.svu-dark {
|
|
6731
|
+
background: ${tokens.bgDark};
|
|
6732
|
+
color: ${tokens.textOnDark};
|
|
6733
|
+
border-color: ${tokens.borderDark};
|
|
6734
|
+
}
|
|
6735
|
+
|
|
6736
|
+
/* ========================================
|
|
6737
|
+
RESPONSIVE
|
|
6738
|
+
======================================== */
|
|
6739
|
+
|
|
6740
|
+
@media (max-width: 768px) {
|
|
6741
|
+
.svu-modal.svu-with-sidebar {
|
|
6742
|
+
flex-direction: column;
|
|
6743
|
+
max-width: 480px;
|
|
6744
|
+
}
|
|
6745
|
+
|
|
6746
|
+
.svu-sidebar {
|
|
6747
|
+
width: 100%;
|
|
6748
|
+
border-left: none;
|
|
6749
|
+
border-top: 1px solid ${tokens.borderDark};
|
|
6750
|
+
max-height: 300px;
|
|
6751
|
+
}
|
|
6752
|
+
}
|
|
6753
|
+
|
|
6754
|
+
@media (max-width: 480px) {
|
|
6755
|
+
.svu-modal {
|
|
6756
|
+
max-width: 100%;
|
|
6757
|
+
max-height: 100%;
|
|
6758
|
+
border-radius: 0;
|
|
6759
|
+
}
|
|
6760
|
+
|
|
6761
|
+
.svu-overlay {
|
|
6762
|
+
padding: 0;
|
|
6763
|
+
}
|
|
6764
|
+
|
|
6765
|
+
.svu-body {
|
|
6766
|
+
padding: 20px;
|
|
6767
|
+
}
|
|
6768
|
+
}
|
|
6769
|
+
|
|
6770
|
+
/* ========================================
|
|
6771
|
+
ACCESSIBILITY
|
|
6772
|
+
======================================== */
|
|
6773
|
+
|
|
6774
|
+
.svu-sr-only {
|
|
6775
|
+
position: absolute;
|
|
6776
|
+
width: 1px;
|
|
6777
|
+
height: 1px;
|
|
6778
|
+
padding: 0;
|
|
6779
|
+
margin: -1px;
|
|
6780
|
+
overflow: hidden;
|
|
6781
|
+
clip: rect(0, 0, 0, 0);
|
|
6782
|
+
white-space: nowrap;
|
|
6783
|
+
border: 0;
|
|
6784
|
+
}
|
|
6785
|
+
`;
|
|
6786
|
+
}
|
|
6787
|
+
|
|
6788
|
+
/**
|
|
6789
|
+
* Upload Modal Container
|
|
6790
|
+
*
|
|
6791
|
+
* Single responsibility: DOM modal container lifecycle.
|
|
6792
|
+
* Creates, shows, hides, and destroys the modal overlay and container.
|
|
6793
|
+
* Does NOT handle content rendering - that's the Renderer's job.
|
|
6794
|
+
*/
|
|
6795
|
+
const OVERLAY_ID = 'sparkvault-upload-overlay';
|
|
6796
|
+
const MODAL_ID = 'sparkvault-upload-modal';
|
|
6797
|
+
/**
|
|
6798
|
+
* Default branding used for immediate modal display before config loads.
|
|
6799
|
+
*/
|
|
6800
|
+
const DEFAULT_BRANDING = {
|
|
6801
|
+
organizationName: '',
|
|
6802
|
+
logoLightUrl: null,
|
|
6803
|
+
logoDarkUrl: null,
|
|
6804
|
+
};
|
|
6805
|
+
class UploadModalContainer {
|
|
6806
|
+
constructor() {
|
|
6807
|
+
this.elements = null;
|
|
6808
|
+
this.onCloseCallback = null;
|
|
6809
|
+
this.keydownHandler = null;
|
|
6810
|
+
this.overlayClickHandler = null;
|
|
6811
|
+
this.closeBtnClickHandler = null;
|
|
6812
|
+
this.closeBtn = null;
|
|
6813
|
+
this.backdropBlur = true;
|
|
6814
|
+
this.isDarkMode = false;
|
|
6815
|
+
this.headerElement = null;
|
|
6816
|
+
this.branding = DEFAULT_BRANDING;
|
|
6817
|
+
}
|
|
6818
|
+
/**
|
|
6819
|
+
* Create and show the modal immediately with loading state.
|
|
6820
|
+
*/
|
|
6821
|
+
createLoading(options, onClose) {
|
|
6822
|
+
this.create({
|
|
6823
|
+
branding: DEFAULT_BRANDING,
|
|
6824
|
+
backdropBlur: options.backdropBlur,
|
|
6825
|
+
}, onClose);
|
|
6826
|
+
}
|
|
6827
|
+
/**
|
|
6828
|
+
* Create and show the modal container.
|
|
6829
|
+
*/
|
|
6830
|
+
create(options, onClose) {
|
|
6831
|
+
if (this.elements) {
|
|
6832
|
+
return;
|
|
6833
|
+
}
|
|
6834
|
+
this.onCloseCallback = onClose;
|
|
6835
|
+
this.backdropBlur = options.backdropBlur !== false;
|
|
6836
|
+
this.branding = options.branding || DEFAULT_BRANDING;
|
|
6837
|
+
injectUploadStyles({
|
|
6838
|
+
branding: this.branding,
|
|
6839
|
+
backdropBlur: this.backdropBlur,
|
|
6840
|
+
});
|
|
6841
|
+
// Create overlay
|
|
6842
|
+
const overlay = document.createElement('div');
|
|
6843
|
+
overlay.id = OVERLAY_ID;
|
|
6844
|
+
overlay.className = this.backdropBlur ? 'svu-overlay svu-blur' : 'svu-overlay';
|
|
6845
|
+
overlay.setAttribute('role', 'dialog');
|
|
6846
|
+
overlay.setAttribute('aria-modal', 'true');
|
|
6847
|
+
overlay.setAttribute('aria-labelledby', 'svu-modal-title');
|
|
6848
|
+
// Create modal
|
|
6849
|
+
const modal = document.createElement('div');
|
|
6850
|
+
modal.id = MODAL_ID;
|
|
6851
|
+
modal.className = 'svu-modal';
|
|
6852
|
+
// Create main content area
|
|
6853
|
+
const main = document.createElement('div');
|
|
6854
|
+
main.className = 'svu-modal-main';
|
|
6855
|
+
// Create header
|
|
6856
|
+
const header = this.createHeader(this.branding);
|
|
6857
|
+
this.headerElement = header;
|
|
6858
|
+
// Create body
|
|
6859
|
+
const body = document.createElement('div');
|
|
6860
|
+
body.className = 'svu-body';
|
|
6861
|
+
// Create footer
|
|
6862
|
+
const footer = document.createElement('div');
|
|
6863
|
+
footer.className = 'svu-footer';
|
|
6864
|
+
const footerText = document.createElement('span');
|
|
6865
|
+
footerText.className = 'svu-footer-text';
|
|
6866
|
+
footerText.appendChild(document.createTextNode('Secured by '));
|
|
6867
|
+
const footerLink = document.createElement('a');
|
|
6868
|
+
footerLink.href = 'https://sparkvault.com';
|
|
6869
|
+
footerLink.target = '_blank';
|
|
6870
|
+
footerLink.rel = 'noopener noreferrer';
|
|
6871
|
+
footerLink.className = 'svu-footer-link';
|
|
6872
|
+
footerLink.textContent = 'SparkVault';
|
|
6873
|
+
footerText.appendChild(footerLink);
|
|
6874
|
+
footer.appendChild(footerText);
|
|
6875
|
+
main.appendChild(header);
|
|
6876
|
+
main.appendChild(body);
|
|
6877
|
+
main.appendChild(footer);
|
|
6878
|
+
modal.appendChild(main);
|
|
6879
|
+
overlay.appendChild(modal);
|
|
6880
|
+
// Event handlers
|
|
6881
|
+
this.overlayClickHandler = (e) => {
|
|
6882
|
+
if (e.target === overlay) {
|
|
6883
|
+
this.handleClose();
|
|
6884
|
+
}
|
|
6885
|
+
};
|
|
6886
|
+
overlay.addEventListener('click', this.overlayClickHandler);
|
|
6887
|
+
this.keydownHandler = (e) => {
|
|
6888
|
+
if (e.key === 'Escape') {
|
|
6889
|
+
this.handleClose();
|
|
6890
|
+
}
|
|
6891
|
+
};
|
|
6892
|
+
document.addEventListener('keydown', this.keydownHandler);
|
|
6893
|
+
document.body.appendChild(overlay);
|
|
6894
|
+
document.body.style.overflow = 'hidden';
|
|
6895
|
+
this.elements = { overlay, modal, main, header, body, footer, sidebar: null };
|
|
6896
|
+
}
|
|
6897
|
+
createHeader(branding) {
|
|
6898
|
+
const header = document.createElement('div');
|
|
6899
|
+
header.className = 'svu-header';
|
|
6900
|
+
const titleContainer = document.createElement('div');
|
|
6901
|
+
titleContainer.className = 'svu-header-title';
|
|
6902
|
+
// Use light logo for light theme (default), dark logo for dark mode
|
|
6903
|
+
const logoUrl = this.isDarkMode
|
|
6904
|
+
? branding.logoDarkUrl || branding.logoLightUrl
|
|
6905
|
+
: branding.logoLightUrl || branding.logoDarkUrl;
|
|
6906
|
+
if (logoUrl) {
|
|
6907
|
+
const logo = document.createElement('img');
|
|
6908
|
+
logo.className = 'svu-logo';
|
|
6909
|
+
logo.src = logoUrl;
|
|
6910
|
+
logo.alt = branding.organizationName;
|
|
6911
|
+
titleContainer.appendChild(logo);
|
|
6912
|
+
// Screen reader title
|
|
6913
|
+
const srTitle = document.createElement('span');
|
|
6914
|
+
srTitle.className = 'svu-sr-only';
|
|
6915
|
+
srTitle.id = 'svu-modal-title';
|
|
6916
|
+
srTitle.textContent = branding.organizationName;
|
|
6917
|
+
titleContainer.appendChild(srTitle);
|
|
6918
|
+
}
|
|
6919
|
+
else if (branding.organizationName) {
|
|
6920
|
+
const companyName = document.createElement('h2');
|
|
6921
|
+
companyName.className = 'svu-company-name';
|
|
6922
|
+
companyName.id = 'svu-modal-title';
|
|
6923
|
+
companyName.textContent = branding.organizationName;
|
|
6924
|
+
titleContainer.appendChild(companyName);
|
|
6925
|
+
}
|
|
6926
|
+
// Close button
|
|
6927
|
+
this.closeBtn = document.createElement('button');
|
|
6928
|
+
this.closeBtn.className = 'svu-close-btn';
|
|
6929
|
+
this.closeBtn.setAttribute('aria-label', 'Close');
|
|
6930
|
+
this.closeBtn.innerHTML = `
|
|
6931
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
6932
|
+
<path d="M18 6L6 18M6 6l12 12"/>
|
|
6933
|
+
</svg>
|
|
6934
|
+
`;
|
|
6935
|
+
this.closeBtnClickHandler = () => this.handleClose();
|
|
6936
|
+
this.closeBtn.addEventListener('click', this.closeBtnClickHandler);
|
|
6937
|
+
header.appendChild(titleContainer);
|
|
6938
|
+
header.appendChild(this.closeBtn);
|
|
6939
|
+
return header;
|
|
6940
|
+
}
|
|
6941
|
+
/**
|
|
6942
|
+
* Update branding after vault config loads.
|
|
6943
|
+
*/
|
|
6944
|
+
updateBranding(branding) {
|
|
6945
|
+
if (!this.elements)
|
|
6946
|
+
return;
|
|
6947
|
+
this.branding = branding;
|
|
6948
|
+
// Update header with real branding
|
|
6949
|
+
const newHeader = this.createHeader(branding);
|
|
6950
|
+
if (this.headerElement?.parentNode) {
|
|
6951
|
+
this.headerElement.parentNode.replaceChild(newHeader, this.headerElement);
|
|
6952
|
+
}
|
|
6953
|
+
this.headerElement = newHeader;
|
|
6954
|
+
}
|
|
6955
|
+
/**
|
|
6956
|
+
* Update backdrop blur setting.
|
|
6957
|
+
*/
|
|
6958
|
+
updateBackdropBlur(enabled) {
|
|
6959
|
+
if (!this.elements)
|
|
6960
|
+
return;
|
|
6961
|
+
this.backdropBlur = enabled;
|
|
6962
|
+
if (enabled) {
|
|
6963
|
+
this.elements.overlay.classList.add('svu-blur');
|
|
6964
|
+
}
|
|
6965
|
+
else {
|
|
6966
|
+
this.elements.overlay.classList.remove('svu-blur');
|
|
6967
|
+
}
|
|
6968
|
+
}
|
|
6969
|
+
/**
|
|
6970
|
+
* Set dark mode for uploading/ceremony states.
|
|
6971
|
+
*/
|
|
6972
|
+
setDarkMode(enabled) {
|
|
6973
|
+
if (!this.elements)
|
|
6974
|
+
return;
|
|
6975
|
+
this.isDarkMode = enabled;
|
|
6976
|
+
if (enabled) {
|
|
6977
|
+
this.elements.modal.classList.add('svu-dark');
|
|
6978
|
+
}
|
|
6979
|
+
else {
|
|
6980
|
+
this.elements.modal.classList.remove('svu-dark');
|
|
6981
|
+
}
|
|
6982
|
+
// Re-create header with correct logo
|
|
6983
|
+
const newHeader = this.createHeader(this.branding);
|
|
6984
|
+
if (this.headerElement?.parentNode) {
|
|
6985
|
+
this.headerElement.parentNode.replaceChild(newHeader, this.headerElement);
|
|
6986
|
+
}
|
|
6987
|
+
this.headerElement = newHeader;
|
|
6988
|
+
}
|
|
6989
|
+
/**
|
|
6990
|
+
* Show or hide the security sidebar.
|
|
6991
|
+
*/
|
|
6992
|
+
toggleSidebar(show) {
|
|
6993
|
+
if (!this.elements)
|
|
6994
|
+
return;
|
|
6995
|
+
if (show && !this.elements.sidebar) {
|
|
6996
|
+
// Create sidebar
|
|
6997
|
+
const sidebar = this.createSecuritySidebar();
|
|
6998
|
+
this.elements.modal.classList.add('svu-with-sidebar');
|
|
6999
|
+
this.elements.modal.appendChild(sidebar);
|
|
7000
|
+
this.elements.sidebar = sidebar;
|
|
7001
|
+
}
|
|
7002
|
+
else if (!show && this.elements.sidebar) {
|
|
7003
|
+
// Remove sidebar
|
|
7004
|
+
this.elements.modal.classList.remove('svu-with-sidebar');
|
|
7005
|
+
this.elements.sidebar.remove();
|
|
7006
|
+
this.elements.sidebar = null;
|
|
7007
|
+
}
|
|
7008
|
+
}
|
|
7009
|
+
createSecuritySidebar() {
|
|
7010
|
+
const sidebar = document.createElement('div');
|
|
7011
|
+
sidebar.className = 'svu-sidebar';
|
|
7012
|
+
// Logo
|
|
7013
|
+
const logoDiv = document.createElement('div');
|
|
7014
|
+
logoDiv.className = 'svu-sidebar-logo';
|
|
7015
|
+
if (this.branding.logoDarkUrl) {
|
|
7016
|
+
const logo = document.createElement('img');
|
|
7017
|
+
logo.src = this.branding.logoDarkUrl;
|
|
7018
|
+
logo.alt = this.branding.organizationName;
|
|
7019
|
+
logoDiv.appendChild(logo);
|
|
7020
|
+
}
|
|
7021
|
+
sidebar.appendChild(logoDiv);
|
|
7022
|
+
// Intro
|
|
7023
|
+
const intro = document.createElement('div');
|
|
7024
|
+
intro.className = 'svu-sidebar-intro';
|
|
7025
|
+
intro.innerHTML = `
|
|
7026
|
+
<h3>Secure File Transfer</h3>
|
|
7027
|
+
<p>This file is secured with SparkVault's advanced multi-key cryptography and end-to-end encryption.</p>
|
|
7028
|
+
`;
|
|
7029
|
+
sidebar.appendChild(intro);
|
|
7030
|
+
// Security details
|
|
7031
|
+
const security = document.createElement('div');
|
|
7032
|
+
security.className = 'svu-sidebar-security';
|
|
7033
|
+
security.innerHTML = `
|
|
7034
|
+
<h4>
|
|
7035
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7036
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
7037
|
+
</svg>
|
|
7038
|
+
SparkVault's Triple Zero-Trust
|
|
7039
|
+
</h4>
|
|
7040
|
+
<p class="svu-security-intro">
|
|
7041
|
+
Decryption requires <strong>three independent Master Keys</strong>, held in three different physical locations, by three separate companies, all cryptographically combined in real-time.
|
|
7042
|
+
</p>
|
|
7043
|
+
<div class="svu-key-chain">
|
|
7044
|
+
<div class="svu-key-item">
|
|
7045
|
+
<div class="svu-key-number">1</div>
|
|
7046
|
+
<div class="svu-key-info">
|
|
7047
|
+
<span class="svu-key-name">Kyber-1024 Post-Quantum Key</span>
|
|
7048
|
+
<span class="svu-key-algo">ML-KEM lattice-based encapsulation</span>
|
|
7049
|
+
<span class="svu-key-desc">Quantum-resistant key exchange</span>
|
|
7050
|
+
</div>
|
|
7051
|
+
</div>
|
|
7052
|
+
<div class="svu-key-connector">
|
|
7053
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7054
|
+
<path d="M12 5v14M5 12h14"/>
|
|
7055
|
+
</svg>
|
|
7056
|
+
</div>
|
|
7057
|
+
<div class="svu-key-item">
|
|
7058
|
+
<div class="svu-key-number">2</div>
|
|
7059
|
+
<div class="svu-key-info">
|
|
7060
|
+
<span class="svu-key-name">X25519 Ephemeral Key</span>
|
|
7061
|
+
<span class="svu-key-algo">Elliptic-curve Diffie-Hellman</span>
|
|
7062
|
+
<span class="svu-key-desc">Perfect forward secrecy</span>
|
|
7063
|
+
</div>
|
|
7064
|
+
</div>
|
|
7065
|
+
<div class="svu-key-connector">
|
|
7066
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7067
|
+
<path d="M12 5v14M5 12h14"/>
|
|
7068
|
+
</svg>
|
|
7069
|
+
</div>
|
|
7070
|
+
<div class="svu-key-item">
|
|
7071
|
+
<div class="svu-key-number">3</div>
|
|
7072
|
+
<div class="svu-key-info">
|
|
7073
|
+
<span class="svu-key-name">HKDF-SHA512 Derived Key</span>
|
|
7074
|
+
<span class="svu-key-algo">Hardware-bound key derivation</span>
|
|
7075
|
+
<span class="svu-key-desc">Vault master key in secure enclave</span>
|
|
7076
|
+
</div>
|
|
7077
|
+
</div>
|
|
7078
|
+
</div>
|
|
7079
|
+
<div class="svu-encryption-result">
|
|
7080
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7081
|
+
<path d="M20 6L9 17l-5-5"/>
|
|
7082
|
+
</svg>
|
|
7083
|
+
<span>Combined via <strong>HKDF</strong> producing <strong>AES-256-GCM</strong> authenticated cipher</span>
|
|
7084
|
+
</div>
|
|
7085
|
+
<div class="svu-security-badges">
|
|
7086
|
+
<span class="svu-tls-badge">TLS 1.3 In Transit</span>
|
|
7087
|
+
<span class="svu-tls-badge">Encrypted At Rest</span>
|
|
7088
|
+
<span class="svu-tls-badge">Zero Knowledge</span>
|
|
7089
|
+
</div>
|
|
7090
|
+
`;
|
|
7091
|
+
sidebar.appendChild(security);
|
|
7092
|
+
return sidebar;
|
|
7093
|
+
}
|
|
7094
|
+
handleClose() {
|
|
7095
|
+
if (this.onCloseCallback) {
|
|
7096
|
+
this.onCloseCallback();
|
|
7097
|
+
}
|
|
7098
|
+
}
|
|
7099
|
+
/**
|
|
7100
|
+
* Get the modal body element for content rendering.
|
|
7101
|
+
*/
|
|
7102
|
+
getBody() {
|
|
7103
|
+
return this.elements?.body ?? null;
|
|
7104
|
+
}
|
|
7105
|
+
/**
|
|
7106
|
+
* Get the sidebar element.
|
|
7107
|
+
*/
|
|
7108
|
+
getSidebar() {
|
|
7109
|
+
return this.elements?.sidebar ?? null;
|
|
7110
|
+
}
|
|
7111
|
+
/**
|
|
7112
|
+
* Check if the modal is currently open.
|
|
7113
|
+
*/
|
|
7114
|
+
isOpen() {
|
|
7115
|
+
return this.elements !== null;
|
|
7116
|
+
}
|
|
7117
|
+
/**
|
|
7118
|
+
* Destroy the modal and clean up all event listeners.
|
|
7119
|
+
*/
|
|
7120
|
+
destroy() {
|
|
7121
|
+
if (this.keydownHandler) {
|
|
7122
|
+
document.removeEventListener('keydown', this.keydownHandler);
|
|
7123
|
+
this.keydownHandler = null;
|
|
7124
|
+
}
|
|
7125
|
+
if (this.elements && this.overlayClickHandler) {
|
|
7126
|
+
this.elements.overlay.removeEventListener('click', this.overlayClickHandler);
|
|
7127
|
+
this.overlayClickHandler = null;
|
|
7128
|
+
}
|
|
7129
|
+
if (this.closeBtn && this.closeBtnClickHandler) {
|
|
7130
|
+
this.closeBtn.removeEventListener('click', this.closeBtnClickHandler);
|
|
7131
|
+
this.closeBtnClickHandler = null;
|
|
7132
|
+
this.closeBtn = null;
|
|
7133
|
+
}
|
|
7134
|
+
if (this.elements) {
|
|
7135
|
+
this.elements.overlay.remove();
|
|
7136
|
+
this.elements = null;
|
|
7137
|
+
}
|
|
7138
|
+
this.headerElement = null;
|
|
7139
|
+
document.body.style.overflow = '';
|
|
7140
|
+
this.onCloseCallback = null;
|
|
7141
|
+
}
|
|
7142
|
+
}
|
|
7143
|
+
|
|
7144
|
+
/**
|
|
7145
|
+
* Inline Upload Container
|
|
7146
|
+
*
|
|
7147
|
+
* Renders upload UI inline within a target element.
|
|
7148
|
+
* Unlike UploadModalContainer which creates an overlay popup, this embeds
|
|
7149
|
+
* directly into the customer's page where render() was called.
|
|
7150
|
+
*
|
|
7151
|
+
* Implements the UploadContainer interface for use with UploadRenderer.
|
|
7152
|
+
*/
|
|
7153
|
+
const DEFAULT_OPTIONS = {
|
|
7154
|
+
showHeader: true,
|
|
7155
|
+
showCloseButton: true,
|
|
7156
|
+
showFooter: true,
|
|
7157
|
+
};
|
|
7158
|
+
class UploadInlineContainer {
|
|
7159
|
+
constructor(targetElement, options = {}) {
|
|
7160
|
+
this.container = null;
|
|
7161
|
+
this.header = null;
|
|
7162
|
+
this.body = null;
|
|
7163
|
+
this.footer = null;
|
|
7164
|
+
this.sidebar = null;
|
|
7165
|
+
this.closeBtn = null;
|
|
7166
|
+
this.onCloseCallback = null;
|
|
7167
|
+
this.closeBtnClickHandler = null;
|
|
7168
|
+
this.isDarkMode = false;
|
|
7169
|
+
this.branding = {
|
|
7170
|
+
organizationName: '',
|
|
7171
|
+
logoLightUrl: null,
|
|
7172
|
+
logoDarkUrl: null,
|
|
7173
|
+
};
|
|
7174
|
+
this.targetElement = targetElement;
|
|
7175
|
+
this.containerOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
7176
|
+
}
|
|
7177
|
+
/**
|
|
7178
|
+
* Create the inline container with loading state.
|
|
7179
|
+
*/
|
|
7180
|
+
createLoading(_options, onClose) {
|
|
7181
|
+
if (this.container) {
|
|
7182
|
+
return;
|
|
7183
|
+
}
|
|
7184
|
+
this.onCloseCallback = onClose;
|
|
7185
|
+
// Inject styles
|
|
7186
|
+
injectUploadStyles({
|
|
7187
|
+
branding: this.branding,
|
|
7188
|
+
backdropBlur: false,
|
|
7189
|
+
});
|
|
7190
|
+
// Create main container
|
|
7191
|
+
this.container = document.createElement('div');
|
|
7192
|
+
this.container.className = 'svu-inline-container';
|
|
7193
|
+
// Create header if enabled
|
|
7194
|
+
if (this.containerOptions.showHeader) {
|
|
7195
|
+
this.header = this.createHeader(this.branding);
|
|
7196
|
+
this.container.appendChild(this.header);
|
|
7197
|
+
}
|
|
7198
|
+
// Create body for view content
|
|
7199
|
+
this.body = document.createElement('div');
|
|
7200
|
+
this.body.className = 'svu-body';
|
|
7201
|
+
this.container.appendChild(this.body);
|
|
7202
|
+
// Create footer if enabled
|
|
7203
|
+
if (this.containerOptions.showFooter) {
|
|
7204
|
+
this.footer = document.createElement('div');
|
|
7205
|
+
this.footer.className = 'svu-footer';
|
|
7206
|
+
const footerText = document.createElement('span');
|
|
7207
|
+
footerText.className = 'svu-footer-text';
|
|
7208
|
+
footerText.appendChild(document.createTextNode('Secured by '));
|
|
7209
|
+
const footerLink = document.createElement('a');
|
|
7210
|
+
footerLink.href = 'https://sparkvault.com';
|
|
7211
|
+
footerLink.target = '_blank';
|
|
7212
|
+
footerLink.rel = 'noopener noreferrer';
|
|
7213
|
+
footerLink.className = 'svu-footer-link';
|
|
7214
|
+
footerLink.textContent = 'SparkVault';
|
|
7215
|
+
footerText.appendChild(footerLink);
|
|
7216
|
+
this.footer.appendChild(footerText);
|
|
7217
|
+
this.container.appendChild(this.footer);
|
|
7218
|
+
}
|
|
7219
|
+
// Clear target and append container
|
|
7220
|
+
this.targetElement.innerHTML = '';
|
|
7221
|
+
this.targetElement.appendChild(this.container);
|
|
7222
|
+
}
|
|
7223
|
+
/**
|
|
7224
|
+
* Update branding after vault config loads.
|
|
7225
|
+
*/
|
|
7226
|
+
updateBranding(branding) {
|
|
7227
|
+
if (!this.container)
|
|
7228
|
+
return;
|
|
7229
|
+
this.branding = branding;
|
|
7230
|
+
// Update header with real branding
|
|
7231
|
+
if (this.containerOptions.showHeader && this.header) {
|
|
7232
|
+
const newHeader = this.createHeader(branding);
|
|
7233
|
+
this.container.replaceChild(newHeader, this.header);
|
|
7234
|
+
this.header = newHeader;
|
|
7235
|
+
}
|
|
7236
|
+
}
|
|
7237
|
+
/**
|
|
7238
|
+
* Update backdrop blur setting (no-op for inline container).
|
|
7239
|
+
*/
|
|
7240
|
+
updateBackdropBlur(_enabled) {
|
|
7241
|
+
// No-op - inline containers don't have backdrop blur
|
|
7242
|
+
}
|
|
7243
|
+
/**
|
|
7244
|
+
* Set dark mode for uploading/ceremony states.
|
|
7245
|
+
*/
|
|
7246
|
+
setDarkMode(enabled) {
|
|
7247
|
+
if (!this.container)
|
|
7248
|
+
return;
|
|
7249
|
+
this.isDarkMode = enabled;
|
|
7250
|
+
if (enabled) {
|
|
7251
|
+
this.container.classList.add('svu-dark');
|
|
7252
|
+
}
|
|
7253
|
+
else {
|
|
7254
|
+
this.container.classList.remove('svu-dark');
|
|
7255
|
+
}
|
|
7256
|
+
// Re-create header with correct logo
|
|
7257
|
+
if (this.containerOptions.showHeader && this.header) {
|
|
7258
|
+
const newHeader = this.createHeader(this.branding);
|
|
7259
|
+
this.container.replaceChild(newHeader, this.header);
|
|
7260
|
+
this.header = newHeader;
|
|
7261
|
+
}
|
|
7262
|
+
}
|
|
7263
|
+
/**
|
|
7264
|
+
* Show or hide the security sidebar.
|
|
7265
|
+
*/
|
|
7266
|
+
toggleSidebar(show) {
|
|
7267
|
+
if (!this.container)
|
|
7268
|
+
return;
|
|
7269
|
+
if (show && !this.sidebar) {
|
|
7270
|
+
// Create sidebar
|
|
7271
|
+
this.sidebar = this.createSecuritySidebar();
|
|
7272
|
+
this.container.classList.add('svu-with-sidebar');
|
|
7273
|
+
this.container.appendChild(this.sidebar);
|
|
7274
|
+
}
|
|
7275
|
+
else if (!show && this.sidebar) {
|
|
7276
|
+
// Remove sidebar
|
|
7277
|
+
this.container.classList.remove('svu-with-sidebar');
|
|
7278
|
+
this.sidebar.remove();
|
|
7279
|
+
this.sidebar = null;
|
|
7280
|
+
}
|
|
7281
|
+
}
|
|
7282
|
+
/**
|
|
7283
|
+
* Get the body element for content rendering.
|
|
7284
|
+
*/
|
|
7285
|
+
getBody() {
|
|
7286
|
+
return this.body;
|
|
7287
|
+
}
|
|
7288
|
+
/**
|
|
7289
|
+
* Get the sidebar element.
|
|
7290
|
+
*/
|
|
7291
|
+
getSidebar() {
|
|
7292
|
+
return this.sidebar;
|
|
7293
|
+
}
|
|
7294
|
+
/**
|
|
7295
|
+
* Check if the container is currently active.
|
|
7296
|
+
*/
|
|
7297
|
+
isOpen() {
|
|
7298
|
+
return this.container !== null;
|
|
7299
|
+
}
|
|
7300
|
+
/**
|
|
7301
|
+
* Destroy the container and clean up.
|
|
7302
|
+
*/
|
|
7303
|
+
destroy() {
|
|
7304
|
+
// Clean up close button handler
|
|
7305
|
+
if (this.closeBtn && this.closeBtnClickHandler) {
|
|
7306
|
+
this.closeBtn.removeEventListener('click', this.closeBtnClickHandler);
|
|
7307
|
+
this.closeBtnClickHandler = null;
|
|
7308
|
+
this.closeBtn = null;
|
|
7309
|
+
}
|
|
7310
|
+
// Clear the target element
|
|
7311
|
+
if (this.container && this.container.parentNode) {
|
|
7312
|
+
this.container.remove();
|
|
7313
|
+
}
|
|
7314
|
+
this.container = null;
|
|
7315
|
+
this.header = null;
|
|
7316
|
+
this.body = null;
|
|
7317
|
+
this.footer = null;
|
|
7318
|
+
this.sidebar = null;
|
|
7319
|
+
this.onCloseCallback = null;
|
|
7320
|
+
}
|
|
7321
|
+
createHeader(branding) {
|
|
7322
|
+
const header = document.createElement('div');
|
|
7323
|
+
header.className = 'svu-header';
|
|
7324
|
+
const titleContainer = document.createElement('div');
|
|
7325
|
+
titleContainer.className = 'svu-header-title';
|
|
7326
|
+
// Use appropriate logo based on dark mode
|
|
7327
|
+
const logoUrl = this.isDarkMode
|
|
7328
|
+
? branding.logoDarkUrl || branding.logoLightUrl
|
|
7329
|
+
: branding.logoLightUrl || branding.logoDarkUrl;
|
|
7330
|
+
if (logoUrl) {
|
|
7331
|
+
const logo = document.createElement('img');
|
|
7332
|
+
logo.className = 'svu-logo';
|
|
7333
|
+
logo.src = logoUrl;
|
|
7334
|
+
logo.alt = branding.organizationName;
|
|
7335
|
+
titleContainer.appendChild(logo);
|
|
7336
|
+
// Screen reader title
|
|
7337
|
+
const srTitle = document.createElement('span');
|
|
7338
|
+
srTitle.className = 'svu-sr-only';
|
|
7339
|
+
srTitle.textContent = branding.organizationName;
|
|
7340
|
+
titleContainer.appendChild(srTitle);
|
|
7341
|
+
}
|
|
7342
|
+
else if (branding.organizationName) {
|
|
7343
|
+
const companyName = document.createElement('h2');
|
|
7344
|
+
companyName.className = 'svu-company-name';
|
|
7345
|
+
companyName.textContent = branding.organizationName;
|
|
7346
|
+
titleContainer.appendChild(companyName);
|
|
7347
|
+
}
|
|
7348
|
+
header.appendChild(titleContainer);
|
|
7349
|
+
// Add close button if enabled
|
|
7350
|
+
if (this.containerOptions.showCloseButton) {
|
|
7351
|
+
this.closeBtn = document.createElement('button');
|
|
7352
|
+
this.closeBtn.className = 'svu-close-btn';
|
|
7353
|
+
this.closeBtn.setAttribute('aria-label', 'Close');
|
|
7354
|
+
this.closeBtn.innerHTML = `
|
|
7355
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7356
|
+
<path d="M18 6L6 18M6 6l12 12"/>
|
|
7357
|
+
</svg>
|
|
7358
|
+
`;
|
|
7359
|
+
this.closeBtnClickHandler = () => this.handleClose();
|
|
7360
|
+
this.closeBtn.addEventListener('click', this.closeBtnClickHandler);
|
|
7361
|
+
header.appendChild(this.closeBtn);
|
|
7362
|
+
}
|
|
7363
|
+
return header;
|
|
7364
|
+
}
|
|
7365
|
+
createSecuritySidebar() {
|
|
7366
|
+
const sidebar = document.createElement('div');
|
|
7367
|
+
sidebar.className = 'svu-sidebar';
|
|
7368
|
+
// Logo
|
|
7369
|
+
const logoDiv = document.createElement('div');
|
|
7370
|
+
logoDiv.className = 'svu-sidebar-logo';
|
|
7371
|
+
if (this.branding.logoDarkUrl) {
|
|
7372
|
+
const logo = document.createElement('img');
|
|
7373
|
+
logo.src = this.branding.logoDarkUrl;
|
|
7374
|
+
logo.alt = this.branding.organizationName;
|
|
7375
|
+
logoDiv.appendChild(logo);
|
|
7376
|
+
}
|
|
7377
|
+
sidebar.appendChild(logoDiv);
|
|
7378
|
+
// Intro
|
|
7379
|
+
const intro = document.createElement('div');
|
|
7380
|
+
intro.className = 'svu-sidebar-intro';
|
|
7381
|
+
intro.innerHTML = `
|
|
7382
|
+
<h3>Secure File Transfer</h3>
|
|
7383
|
+
<p>This file is secured with SparkVault's advanced multi-key cryptography and end-to-end encryption.</p>
|
|
7384
|
+
`;
|
|
7385
|
+
sidebar.appendChild(intro);
|
|
7386
|
+
// Security details
|
|
7387
|
+
const security = document.createElement('div');
|
|
7388
|
+
security.className = 'svu-sidebar-security';
|
|
7389
|
+
security.innerHTML = `
|
|
7390
|
+
<h4>
|
|
7391
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7392
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
7393
|
+
</svg>
|
|
7394
|
+
SparkVault's Triple Zero-Trust
|
|
7395
|
+
</h4>
|
|
7396
|
+
<p class="svu-security-intro">
|
|
7397
|
+
Decryption requires <strong>three independent Master Keys</strong>, held in three different physical locations, by three separate companies, all cryptographically combined in real-time.
|
|
7398
|
+
</p>
|
|
7399
|
+
<div class="svu-key-chain">
|
|
7400
|
+
<div class="svu-key-item">
|
|
7401
|
+
<div class="svu-key-number">1</div>
|
|
7402
|
+
<div class="svu-key-info">
|
|
7403
|
+
<span class="svu-key-name">Kyber-1024 Post-Quantum Key</span>
|
|
7404
|
+
<span class="svu-key-algo">ML-KEM lattice-based encapsulation</span>
|
|
7405
|
+
<span class="svu-key-desc">Quantum-resistant key exchange</span>
|
|
7406
|
+
</div>
|
|
7407
|
+
</div>
|
|
7408
|
+
<div class="svu-key-connector">
|
|
7409
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7410
|
+
<path d="M12 5v14M5 12h14"/>
|
|
7411
|
+
</svg>
|
|
7412
|
+
</div>
|
|
7413
|
+
<div class="svu-key-item">
|
|
7414
|
+
<div class="svu-key-number">2</div>
|
|
7415
|
+
<div class="svu-key-info">
|
|
7416
|
+
<span class="svu-key-name">X25519 Ephemeral Key</span>
|
|
7417
|
+
<span class="svu-key-algo">Elliptic-curve Diffie-Hellman</span>
|
|
7418
|
+
<span class="svu-key-desc">Perfect forward secrecy</span>
|
|
7419
|
+
</div>
|
|
7420
|
+
</div>
|
|
7421
|
+
<div class="svu-key-connector">
|
|
7422
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7423
|
+
<path d="M12 5v14M5 12h14"/>
|
|
7424
|
+
</svg>
|
|
7425
|
+
</div>
|
|
7426
|
+
<div class="svu-key-item">
|
|
7427
|
+
<div class="svu-key-number">3</div>
|
|
7428
|
+
<div class="svu-key-info">
|
|
7429
|
+
<span class="svu-key-name">HKDF-SHA512 Derived Key</span>
|
|
7430
|
+
<span class="svu-key-algo">Hardware-bound key derivation</span>
|
|
7431
|
+
<span class="svu-key-desc">Vault master key in secure enclave</span>
|
|
7432
|
+
</div>
|
|
7433
|
+
</div>
|
|
7434
|
+
</div>
|
|
7435
|
+
<div class="svu-encryption-result">
|
|
7436
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7437
|
+
<path d="M20 6L9 17l-5-5"/>
|
|
7438
|
+
</svg>
|
|
7439
|
+
<span>Combined via <strong>HKDF</strong> producing <strong>AES-256-GCM</strong> authenticated cipher</span>
|
|
7440
|
+
</div>
|
|
7441
|
+
<div class="svu-security-badges">
|
|
7442
|
+
<span class="svu-tls-badge">TLS 1.3 In Transit</span>
|
|
7443
|
+
<span class="svu-tls-badge">Encrypted At Rest</span>
|
|
7444
|
+
<span class="svu-tls-badge">Zero Knowledge</span>
|
|
7445
|
+
</div>
|
|
7446
|
+
`;
|
|
7447
|
+
sidebar.appendChild(security);
|
|
7448
|
+
return sidebar;
|
|
7449
|
+
}
|
|
7450
|
+
handleClose() {
|
|
7451
|
+
if (this.onCloseCallback) {
|
|
7452
|
+
this.onCloseCallback();
|
|
7453
|
+
}
|
|
7454
|
+
}
|
|
7455
|
+
}
|
|
7456
|
+
|
|
7457
|
+
/**
|
|
7458
|
+
* Upload Renderer
|
|
7459
|
+
*
|
|
7460
|
+
* Orchestrates view state and rendering for vault upload flows.
|
|
7461
|
+
* Manages the complete upload lifecycle: form, uploading, ceremony, completion.
|
|
7462
|
+
*/
|
|
7463
|
+
/**
|
|
7464
|
+
* Ceremony steps for the cryptographic visualization.
|
|
7465
|
+
*/
|
|
7466
|
+
const CEREMONY_STEPS = [
|
|
7467
|
+
{ text: 'File received by Forge', duration: 600 },
|
|
7468
|
+
{ text: 'Deriving Triple Zero-Trust encryption keys', duration: 800 },
|
|
7469
|
+
{ text: 'Encrypting with AES-256-GCM', duration: 900 },
|
|
7470
|
+
{ text: 'Generating cryptographic signatures', duration: 700 },
|
|
7471
|
+
{ text: 'Storing in post-quantum protected vault', duration: 600 },
|
|
7472
|
+
{ text: 'Verifying integrity', duration: 400 },
|
|
7473
|
+
];
|
|
7474
|
+
/**
|
|
7475
|
+
* Convert hex string to base64url for display.
|
|
7476
|
+
*/
|
|
7477
|
+
function hexToBase64url(hex) {
|
|
7478
|
+
const bytes = new Uint8Array(hex.match(/.{2}/g).map(byte => parseInt(byte, 16)));
|
|
7479
|
+
const base64 = btoa(String.fromCharCode(...bytes));
|
|
7480
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
7481
|
+
}
|
|
7482
|
+
/**
|
|
7483
|
+
* Generate a request ID for display.
|
|
7484
|
+
*/
|
|
7485
|
+
function generateRequestId() {
|
|
7486
|
+
const timestamp = Date.now().toString(16);
|
|
7487
|
+
const random = Math.random().toString(16).substring(2, 10);
|
|
7488
|
+
return `${timestamp}${random}`;
|
|
7489
|
+
}
|
|
7490
|
+
/**
|
|
7491
|
+
* Format bytes to human readable string.
|
|
7492
|
+
*/
|
|
7493
|
+
function formatBytes(bytes) {
|
|
7494
|
+
if (bytes === 0)
|
|
7495
|
+
return '0 B';
|
|
7496
|
+
const k = 1024;
|
|
7497
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
7498
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
7499
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
7500
|
+
}
|
|
7501
|
+
class UploadRenderer {
|
|
7502
|
+
constructor(container, api, options, callbacks) {
|
|
7503
|
+
this.viewState = { view: 'loading' };
|
|
7504
|
+
this.config = null;
|
|
7505
|
+
this.selectedFile = null;
|
|
7506
|
+
// File input element for selection
|
|
7507
|
+
this.fileInputElement = null;
|
|
7508
|
+
this.pasteHandler = null;
|
|
7509
|
+
this.container = container;
|
|
7510
|
+
this.api = api;
|
|
7511
|
+
this.options = options;
|
|
7512
|
+
this.callbacks = callbacks;
|
|
7513
|
+
}
|
|
7514
|
+
/**
|
|
7515
|
+
* Start the upload flow.
|
|
7516
|
+
*/
|
|
7517
|
+
async start() {
|
|
7518
|
+
// Create container with loading state
|
|
7519
|
+
this.container.createLoading({ backdropBlur: this.options.backdropBlur }, () => this.handleClose());
|
|
7520
|
+
this.setState({ view: 'loading' });
|
|
7521
|
+
try {
|
|
7522
|
+
// Minimum delay for UX
|
|
7523
|
+
const [config] = await Promise.all([
|
|
7524
|
+
this.api.getVaultUploadInfo(this.options.vaultId),
|
|
7525
|
+
new Promise(resolve => setTimeout(resolve, 1000)),
|
|
7526
|
+
]);
|
|
7527
|
+
this.config = config;
|
|
7528
|
+
// Update branding
|
|
7529
|
+
this.container.updateBranding(config.branding);
|
|
7530
|
+
// Show form
|
|
7531
|
+
this.setState({ view: 'form', config });
|
|
7532
|
+
}
|
|
7533
|
+
catch (error) {
|
|
7534
|
+
this.handleApiError(error);
|
|
7535
|
+
}
|
|
7536
|
+
}
|
|
7537
|
+
/**
|
|
7538
|
+
* Close the upload flow.
|
|
7539
|
+
*/
|
|
7540
|
+
close() {
|
|
7541
|
+
this.cleanupFileInput();
|
|
7542
|
+
this.cleanupPasteHandler();
|
|
7543
|
+
this.container.destroy();
|
|
7544
|
+
}
|
|
7545
|
+
handleClose() {
|
|
7546
|
+
this.close();
|
|
7547
|
+
this.callbacks.onCancel();
|
|
7548
|
+
}
|
|
7549
|
+
setState(state) {
|
|
7550
|
+
this.viewState = state;
|
|
7551
|
+
this.render();
|
|
7552
|
+
}
|
|
7553
|
+
render() {
|
|
7554
|
+
const body = this.container.getBody();
|
|
7555
|
+
if (!body)
|
|
7556
|
+
return;
|
|
7557
|
+
// Clear body
|
|
7558
|
+
body.innerHTML = '';
|
|
7559
|
+
// Handle dark mode for certain states
|
|
7560
|
+
const containerWithDarkMode = this.container;
|
|
7561
|
+
if ('setDarkMode' in containerWithDarkMode) {
|
|
7562
|
+
const isDarkState = this.viewState.view === 'uploading' || this.viewState.view === 'ceremony';
|
|
7563
|
+
containerWithDarkMode.setDarkMode(isDarkState);
|
|
7564
|
+
// Show sidebar during dark states (uploading/ceremony)
|
|
7565
|
+
this.container.toggleSidebar(isDarkState);
|
|
7566
|
+
}
|
|
7567
|
+
switch (this.viewState.view) {
|
|
7568
|
+
case 'loading':
|
|
7569
|
+
body.appendChild(this.renderLoading());
|
|
7570
|
+
break;
|
|
7571
|
+
case 'form':
|
|
7572
|
+
body.appendChild(this.renderForm(this.viewState.config, this.viewState.error));
|
|
7573
|
+
break;
|
|
7574
|
+
case 'uploading':
|
|
7575
|
+
body.appendChild(this.renderUploading(this.viewState));
|
|
7576
|
+
break;
|
|
7577
|
+
case 'ceremony':
|
|
7578
|
+
body.appendChild(this.renderCeremony(this.viewState));
|
|
7579
|
+
break;
|
|
7580
|
+
case 'complete':
|
|
7581
|
+
body.appendChild(this.renderComplete(this.viewState.result, this.viewState.config));
|
|
7582
|
+
break;
|
|
7583
|
+
case 'error':
|
|
7584
|
+
body.appendChild(this.renderError(this.viewState));
|
|
7585
|
+
break;
|
|
7586
|
+
}
|
|
7587
|
+
}
|
|
7588
|
+
renderLoading() {
|
|
7589
|
+
const div = document.createElement('div');
|
|
7590
|
+
div.className = 'svu-loading';
|
|
7591
|
+
div.innerHTML = `
|
|
7592
|
+
<div class="svu-spinner"></div>
|
|
7593
|
+
<p class="svu-loading-text">Establishing secure connection...</p>
|
|
7594
|
+
`;
|
|
7595
|
+
return div;
|
|
7596
|
+
}
|
|
7597
|
+
renderForm(config, error) {
|
|
7598
|
+
const div = document.createElement('div');
|
|
7599
|
+
div.className = 'svu-form-view';
|
|
7600
|
+
// Title
|
|
7601
|
+
const title = document.createElement('h2');
|
|
7602
|
+
title.className = 'svu-title';
|
|
7603
|
+
title.textContent = 'Cryptographically Secure File Transfer';
|
|
7604
|
+
div.appendChild(title);
|
|
7605
|
+
// Subtitle with vault name
|
|
7606
|
+
const subtitle = document.createElement('p');
|
|
7607
|
+
subtitle.className = 'svu-subtitle';
|
|
7608
|
+
subtitle.innerHTML = `Destination Vault: <strong>${this.escapeHtml(config.vaultName)}</strong>`;
|
|
7609
|
+
div.appendChild(subtitle);
|
|
7610
|
+
// Error message
|
|
7611
|
+
if (error) {
|
|
7612
|
+
const alert = document.createElement('div');
|
|
7613
|
+
alert.className = 'svu-error-alert';
|
|
7614
|
+
alert.textContent = error;
|
|
7615
|
+
div.appendChild(alert);
|
|
7616
|
+
}
|
|
7617
|
+
// Drop zone
|
|
7618
|
+
const dropZone = document.createElement('div');
|
|
7619
|
+
dropZone.className = `svu-drop-zone${this.selectedFile ? ' svu-has-file' : ''}`;
|
|
7620
|
+
if (this.selectedFile) {
|
|
7621
|
+
dropZone.innerHTML = `
|
|
7622
|
+
<div class="svu-selected-file">
|
|
7623
|
+
<div class="svu-file-icon">
|
|
7624
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
7625
|
+
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
|
7626
|
+
<path d="M14 2v6h6"/>
|
|
7627
|
+
</svg>
|
|
7628
|
+
</div>
|
|
7629
|
+
<div class="svu-file-info">
|
|
7630
|
+
<span class="svu-file-name">${this.escapeHtml(this.selectedFile.name)}</span>
|
|
7631
|
+
<span class="svu-file-size">${formatBytes(this.selectedFile.size)}</span>
|
|
7632
|
+
</div>
|
|
7633
|
+
<button class="svu-remove-file" aria-label="Remove file">×</button>
|
|
7634
|
+
</div>
|
|
7635
|
+
`;
|
|
7636
|
+
const removeBtn = dropZone.querySelector('.svu-remove-file');
|
|
7637
|
+
if (removeBtn) {
|
|
7638
|
+
removeBtn.addEventListener('click', (e) => {
|
|
7639
|
+
e.stopPropagation();
|
|
7640
|
+
this.selectedFile = null;
|
|
7641
|
+
this.render();
|
|
7642
|
+
});
|
|
7643
|
+
}
|
|
7644
|
+
}
|
|
7645
|
+
else {
|
|
7646
|
+
dropZone.innerHTML = `
|
|
7647
|
+
<div class="svu-drop-icon">
|
|
7648
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
7649
|
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
|
7650
|
+
<path d="M17 8l-5-5-5 5"/>
|
|
7651
|
+
<path d="M12 3v12"/>
|
|
7652
|
+
</svg>
|
|
7653
|
+
</div>
|
|
7654
|
+
<p class="svu-drop-text">Drag & drop a file here</p>
|
|
7655
|
+
<p class="svu-drop-subtext">or click to select</p>
|
|
7656
|
+
<p class="svu-drop-hint">Paste from clipboard also supported (Ctrl+V)</p>
|
|
7657
|
+
`;
|
|
7658
|
+
}
|
|
7659
|
+
// Drop zone events
|
|
7660
|
+
dropZone.addEventListener('dragover', (e) => {
|
|
7661
|
+
e.preventDefault();
|
|
7662
|
+
dropZone.classList.add('svu-dragging');
|
|
7663
|
+
});
|
|
7664
|
+
dropZone.addEventListener('dragleave', (e) => {
|
|
7665
|
+
e.preventDefault();
|
|
7666
|
+
dropZone.classList.remove('svu-dragging');
|
|
7667
|
+
});
|
|
7668
|
+
dropZone.addEventListener('drop', (e) => {
|
|
7669
|
+
e.preventDefault();
|
|
7670
|
+
dropZone.classList.remove('svu-dragging');
|
|
7671
|
+
const file = e.dataTransfer?.files[0];
|
|
7672
|
+
if (file) {
|
|
7673
|
+
this.handleFileSelect(file, config);
|
|
7674
|
+
}
|
|
7675
|
+
});
|
|
7676
|
+
dropZone.addEventListener('click', () => {
|
|
7677
|
+
if (!this.selectedFile) {
|
|
7678
|
+
this.openFileSelector(config);
|
|
7679
|
+
}
|
|
7680
|
+
});
|
|
7681
|
+
div.appendChild(dropZone);
|
|
7682
|
+
// Max size
|
|
7683
|
+
const maxSize = document.createElement('p');
|
|
7684
|
+
maxSize.className = 'svu-max-size';
|
|
7685
|
+
maxSize.textContent = `Max upload size: ${formatBytes(config.maxSizeBytes)}`;
|
|
7686
|
+
div.appendChild(maxSize);
|
|
7687
|
+
// Upload button
|
|
7688
|
+
if (this.selectedFile) {
|
|
7689
|
+
const uploadBtn = document.createElement('button');
|
|
7690
|
+
uploadBtn.className = 'svu-btn svu-btn-primary';
|
|
7691
|
+
uploadBtn.textContent = 'Upload Securely';
|
|
7692
|
+
uploadBtn.onclick = () => this.startUpload(config);
|
|
7693
|
+
div.appendChild(uploadBtn);
|
|
7694
|
+
}
|
|
7695
|
+
// Metadata section
|
|
7696
|
+
const metadata = document.createElement('div');
|
|
7697
|
+
metadata.className = 'svu-metadata';
|
|
7698
|
+
metadata.innerHTML = `
|
|
7699
|
+
<h4>Transfer Details</h4>
|
|
7700
|
+
<div class="svu-metadata-grid">
|
|
7701
|
+
<div class="svu-metadata-item">
|
|
7702
|
+
<span class="svu-metadata-label">Recipient</span>
|
|
7703
|
+
<span class="svu-metadata-value">${this.escapeHtml(config.branding.organizationName)}</span>
|
|
7704
|
+
</div>
|
|
7705
|
+
<div class="svu-metadata-item">
|
|
7706
|
+
<span class="svu-metadata-label">Vault</span>
|
|
7707
|
+
<span class="svu-metadata-value">${this.escapeHtml(config.vaultName)}</span>
|
|
7708
|
+
</div>
|
|
7709
|
+
<div class="svu-metadata-item svu-full-width">
|
|
7710
|
+
<span class="svu-metadata-label">Vault ID</span>
|
|
7711
|
+
<span class="svu-metadata-value">${this.escapeHtml(config.vaultId)}</span>
|
|
7712
|
+
</div>
|
|
7713
|
+
<div class="svu-metadata-item">
|
|
7714
|
+
<span class="svu-metadata-label">Encryption</span>
|
|
7715
|
+
<span class="svu-metadata-value">${this.escapeHtml(config.encryption.algorithm)}</span>
|
|
7716
|
+
</div>
|
|
7717
|
+
<div class="svu-metadata-item">
|
|
7718
|
+
<span class="svu-metadata-label">Key Derivation</span>
|
|
7719
|
+
<span class="svu-metadata-value">${this.escapeHtml(config.encryption.keyDerivation)}</span>
|
|
7720
|
+
</div>
|
|
7721
|
+
<div class="svu-metadata-item">
|
|
7722
|
+
<span class="svu-metadata-label">Post-Quantum</span>
|
|
7723
|
+
<span class="svu-metadata-value">${this.escapeHtml(config.encryption.postQuantum)}</span>
|
|
7724
|
+
</div>
|
|
7725
|
+
<div class="svu-metadata-item">
|
|
7726
|
+
<span class="svu-metadata-label">Forge Status</span>
|
|
7727
|
+
<span class="svu-metadata-value ${config.forgeStatus === 'active' ? 'svu-status-active' : ''}">
|
|
7728
|
+
${config.forgeStatus === 'active' ? '● Active' : '○ Inactive'}
|
|
7729
|
+
</span>
|
|
7730
|
+
</div>
|
|
7731
|
+
</div>
|
|
7732
|
+
`;
|
|
7733
|
+
div.appendChild(metadata);
|
|
7734
|
+
// Setup paste handler
|
|
7735
|
+
this.setupPasteHandler(config);
|
|
7736
|
+
return div;
|
|
7737
|
+
}
|
|
7738
|
+
renderUploading(state) {
|
|
7739
|
+
const div = document.createElement('div');
|
|
7740
|
+
div.className = 'svu-uploading-view';
|
|
7741
|
+
const displayIngotId = hexToBase64url(state.ingotId.replace('ing_', ''));
|
|
7742
|
+
div.innerHTML = `
|
|
7743
|
+
<h2 class="svu-title">Streaming & Encrypting</h2>
|
|
7744
|
+
|
|
7745
|
+
<div class="svu-stages">
|
|
7746
|
+
<div class="svu-stage svu-active">
|
|
7747
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
7748
|
+
<rect x="2" y="3" width="20" height="18" rx="2"/>
|
|
7749
|
+
<path d="M2 9h20"/>
|
|
7750
|
+
<circle cx="6" cy="6" r="1" fill="currentColor"/>
|
|
7751
|
+
<circle cx="10" cy="6" r="1" fill="currentColor"/>
|
|
7752
|
+
</svg>
|
|
7753
|
+
<span>Your Browser</span>
|
|
7754
|
+
</div>
|
|
7755
|
+
<div class="svu-stage-arrow svu-active"></div>
|
|
7756
|
+
<div class="svu-stage svu-active">
|
|
7757
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
7758
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
7759
|
+
</svg>
|
|
7760
|
+
<span>SparkVault</span>
|
|
7761
|
+
<span class="svu-stage-sub">Encrypting</span>
|
|
7762
|
+
</div>
|
|
7763
|
+
<div class="svu-stage-arrow"></div>
|
|
7764
|
+
<div class="svu-stage">
|
|
7765
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
7766
|
+
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
7767
|
+
<circle cx="12" cy="12" r="3"/>
|
|
7768
|
+
<path d="M12 9V6M12 18v-3M9 12H6M18 12h-3"/>
|
|
7769
|
+
</svg>
|
|
7770
|
+
<span>${this.escapeHtml(this.config?.branding.organizationName || '')}</span>
|
|
7771
|
+
</div>
|
|
7772
|
+
</div>
|
|
7773
|
+
|
|
7774
|
+
<div class="svu-progress">
|
|
7775
|
+
<div class="svu-progress-bar">
|
|
7776
|
+
<div class="svu-progress-fill" style="width: ${state.progress}%"></div>
|
|
7777
|
+
</div>
|
|
7778
|
+
<div class="svu-progress-text">
|
|
7779
|
+
<span>${state.progress}%</span>
|
|
7780
|
+
<span>${formatBytes(state.bytesUploaded)} / ${formatBytes(state.file.size)}</span>
|
|
7781
|
+
</div>
|
|
7782
|
+
</div>
|
|
7783
|
+
|
|
7784
|
+
<div class="svu-forging-info">
|
|
7785
|
+
<p class="svu-forging-line">Forging Ingot ID: <span class="svu-mono">${displayIngotId}</span></p>
|
|
7786
|
+
<p class="svu-forging-line">Secure Transfer Channel: <span class="svu-mono">${state.requestId}</span></p>
|
|
7787
|
+
</div>
|
|
7788
|
+
`;
|
|
7789
|
+
return div;
|
|
7790
|
+
}
|
|
7791
|
+
renderCeremony(state) {
|
|
7792
|
+
const div = document.createElement('div');
|
|
7793
|
+
div.className = 'svu-ceremony-view';
|
|
7794
|
+
const displayIngotId = hexToBase64url(state.ingotId.replace('ing_', ''));
|
|
7795
|
+
let stepsHtml = '';
|
|
7796
|
+
CEREMONY_STEPS.forEach((step, index) => {
|
|
7797
|
+
const isComplete = index < state.step;
|
|
7798
|
+
const isActive = index === state.step;
|
|
7799
|
+
stepsHtml += `
|
|
7800
|
+
<div class="svu-ceremony-step ${isComplete ? 'svu-complete' : ''} ${isActive ? 'svu-active' : ''}">
|
|
7801
|
+
<span class="svu-step-icon">
|
|
7802
|
+
${isComplete
|
|
7803
|
+
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>'
|
|
7804
|
+
: isActive
|
|
7805
|
+
? '<svg class="svu-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>'
|
|
7806
|
+
: ''}
|
|
7807
|
+
</span>
|
|
7808
|
+
<span class="svu-step-text">${step.text}</span>
|
|
7809
|
+
</div>
|
|
7810
|
+
`;
|
|
7811
|
+
});
|
|
7812
|
+
div.innerHTML = `
|
|
7813
|
+
<h2 class="svu-title">Securing Your File</h2>
|
|
7814
|
+
|
|
7815
|
+
<div class="svu-ceremony-steps">
|
|
7816
|
+
${stepsHtml}
|
|
7817
|
+
</div>
|
|
7818
|
+
|
|
7819
|
+
<div class="svu-forging-info">
|
|
7820
|
+
<p class="svu-forging-line">Forging Ingot ID: <span class="svu-mono">${displayIngotId}</span></p>
|
|
7821
|
+
<p class="svu-forging-line">Secure Transfer Channel: <span class="svu-mono">${state.requestId}</span></p>
|
|
7822
|
+
</div>
|
|
7823
|
+
`;
|
|
7824
|
+
return div;
|
|
7825
|
+
}
|
|
7826
|
+
renderComplete(result, config) {
|
|
7827
|
+
const div = document.createElement('div');
|
|
7828
|
+
div.className = 'svu-complete-view';
|
|
7829
|
+
div.innerHTML = `
|
|
7830
|
+
<div class="svu-success-icon">
|
|
7831
|
+
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
7832
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
7833
|
+
<path d="M22 4L12 14.01l-3-3"/>
|
|
7834
|
+
</svg>
|
|
7835
|
+
</div>
|
|
7836
|
+
|
|
7837
|
+
<h2 class="svu-success-title">Your file has been securely sent!</h2>
|
|
7838
|
+
|
|
7839
|
+
<div class="svu-success-details">
|
|
7840
|
+
<div class="svu-success-row">
|
|
7841
|
+
<span class="svu-success-label">Ingot ID</span>
|
|
7842
|
+
<span class="svu-success-value svu-mono">
|
|
7843
|
+
${this.escapeHtml(result.ingotId)}
|
|
7844
|
+
<button class="svu-copy-btn" aria-label="Copy Ingot ID">
|
|
7845
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7846
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
7847
|
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
|
7848
|
+
</svg>
|
|
7849
|
+
</button>
|
|
7850
|
+
</span>
|
|
7851
|
+
</div>
|
|
7852
|
+
<div class="svu-success-row">
|
|
7853
|
+
<span class="svu-success-label">File</span>
|
|
7854
|
+
<span class="svu-success-value">${this.escapeHtml(result.filename)}</span>
|
|
7855
|
+
</div>
|
|
7856
|
+
<div class="svu-success-row">
|
|
7857
|
+
<span class="svu-success-label">Size</span>
|
|
7858
|
+
<span class="svu-success-value">${formatBytes(result.sizeBytes)}</span>
|
|
7859
|
+
</div>
|
|
7860
|
+
<div class="svu-success-row">
|
|
7861
|
+
<span class="svu-success-label">Timestamp</span>
|
|
7862
|
+
<span class="svu-success-value svu-mono">${this.escapeHtml(result.uploadTime)}</span>
|
|
7863
|
+
</div>
|
|
7864
|
+
<div class="svu-success-row">
|
|
7865
|
+
<span class="svu-success-label">Encryption</span>
|
|
7866
|
+
<span class="svu-success-value">AES-256-GCM</span>
|
|
7867
|
+
</div>
|
|
7868
|
+
</div>
|
|
7869
|
+
|
|
7870
|
+
<p class="svu-success-guidance">
|
|
7871
|
+
${this.escapeHtml(config.branding.organizationName)} has been notified and can access this file
|
|
7872
|
+
from their SparkVault dashboard. Save the Ingot ID above for your records.
|
|
7873
|
+
</p>
|
|
7874
|
+
|
|
7875
|
+
<button class="svu-btn svu-btn-secondary">
|
|
7876
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7877
|
+
<path d="M1 4v6h6M23 20v-6h-6"/>
|
|
7878
|
+
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15"/>
|
|
7879
|
+
</svg>
|
|
7880
|
+
Upload Another File
|
|
7881
|
+
</button>
|
|
7882
|
+
`;
|
|
7883
|
+
// Copy button handler
|
|
7884
|
+
const copyBtn = div.querySelector('.svu-copy-btn');
|
|
7885
|
+
if (copyBtn) {
|
|
7886
|
+
copyBtn.addEventListener('click', () => {
|
|
7887
|
+
navigator.clipboard.writeText(result.ingotId);
|
|
7888
|
+
});
|
|
7889
|
+
}
|
|
7890
|
+
// Upload another handler
|
|
7891
|
+
const uploadAnotherBtn = div.querySelector('.svu-btn-secondary');
|
|
7892
|
+
if (uploadAnotherBtn) {
|
|
7893
|
+
uploadAnotherBtn.addEventListener('click', () => {
|
|
7894
|
+
this.selectedFile = null;
|
|
7895
|
+
this.setState({ view: 'form', config });
|
|
7896
|
+
});
|
|
7897
|
+
}
|
|
7898
|
+
return div;
|
|
7899
|
+
}
|
|
7900
|
+
renderError(state) {
|
|
7901
|
+
const div = document.createElement('div');
|
|
7902
|
+
div.className = 'svu-error-view';
|
|
7903
|
+
const statusCode = state.httpStatus || 404;
|
|
7904
|
+
const statusText = statusCode === 402 ? 'Service Unavailable' : 'Resource Not Found';
|
|
7905
|
+
div.innerHTML = `
|
|
7906
|
+
<div class="svu-error-icon">
|
|
7907
|
+
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
7908
|
+
<circle cx="12" cy="12" r="10"/>
|
|
7909
|
+
<path d="M12 8v4M12 16h.01"/>
|
|
7910
|
+
</svg>
|
|
7911
|
+
</div>
|
|
7912
|
+
|
|
7913
|
+
<h2 class="svu-error-title">${statusCode} - ${statusText}</h2>
|
|
7914
|
+
|
|
7915
|
+
<p class="svu-error-message">${this.escapeHtml(state.message)}</p>
|
|
7916
|
+
|
|
7917
|
+
<div class="svu-error-code">
|
|
7918
|
+
<span class="svu-error-code-label">Code</span>
|
|
7919
|
+
<span class="svu-error-code-value">${this.escapeHtml(state.code)}</span>
|
|
7920
|
+
</div>
|
|
7921
|
+
`;
|
|
7922
|
+
return div;
|
|
7923
|
+
}
|
|
7924
|
+
handleFileSelect(file, config) {
|
|
7925
|
+
// Validate file size
|
|
7926
|
+
if (file.size > config.maxSizeBytes) {
|
|
7927
|
+
this.setState({
|
|
7928
|
+
view: 'form',
|
|
7929
|
+
config,
|
|
7930
|
+
error: `File size exceeds maximum allowed (${formatBytes(config.maxSizeBytes)})`,
|
|
7931
|
+
});
|
|
7932
|
+
return;
|
|
7933
|
+
}
|
|
7934
|
+
this.selectedFile = file;
|
|
7935
|
+
this.setState({ view: 'form', config });
|
|
7936
|
+
}
|
|
7937
|
+
openFileSelector(config) {
|
|
7938
|
+
this.cleanupFileInput();
|
|
7939
|
+
this.fileInputElement = document.createElement('input');
|
|
7940
|
+
this.fileInputElement.type = 'file';
|
|
7941
|
+
this.fileInputElement.style.display = 'none';
|
|
7942
|
+
this.fileInputElement.onchange = () => {
|
|
7943
|
+
const file = this.fileInputElement?.files?.[0];
|
|
7944
|
+
if (file) {
|
|
7945
|
+
this.handleFileSelect(file, config);
|
|
7946
|
+
}
|
|
7947
|
+
};
|
|
7948
|
+
document.body.appendChild(this.fileInputElement);
|
|
7949
|
+
this.fileInputElement.click();
|
|
7950
|
+
}
|
|
7951
|
+
cleanupFileInput() {
|
|
7952
|
+
if (this.fileInputElement) {
|
|
7953
|
+
this.fileInputElement.remove();
|
|
7954
|
+
this.fileInputElement = null;
|
|
7955
|
+
}
|
|
7956
|
+
}
|
|
7957
|
+
setupPasteHandler(config) {
|
|
7958
|
+
// Remove any existing handler first
|
|
7959
|
+
this.cleanupPasteHandler();
|
|
7960
|
+
this.pasteHandler = (e) => {
|
|
7961
|
+
const items = e.clipboardData?.items;
|
|
7962
|
+
if (!items)
|
|
7963
|
+
return;
|
|
7964
|
+
for (const item of items) {
|
|
7965
|
+
if (item.kind === 'file') {
|
|
7966
|
+
const file = item.getAsFile();
|
|
7967
|
+
if (file) {
|
|
7968
|
+
this.handleFileSelect(file, config);
|
|
7969
|
+
break;
|
|
7970
|
+
}
|
|
7971
|
+
}
|
|
7972
|
+
}
|
|
7973
|
+
};
|
|
7974
|
+
document.addEventListener('paste', this.pasteHandler);
|
|
7975
|
+
}
|
|
7976
|
+
cleanupPasteHandler() {
|
|
7977
|
+
if (this.pasteHandler) {
|
|
7978
|
+
document.removeEventListener('paste', this.pasteHandler);
|
|
7979
|
+
this.pasteHandler = null;
|
|
7980
|
+
}
|
|
7981
|
+
}
|
|
7982
|
+
async startUpload(config) {
|
|
7983
|
+
if (!this.selectedFile)
|
|
7984
|
+
return;
|
|
7985
|
+
const file = this.selectedFile;
|
|
7986
|
+
const requestId = generateRequestId();
|
|
7987
|
+
try {
|
|
7988
|
+
// Initiate upload
|
|
7989
|
+
const { forgeUrl, ingotId } = await this.api.initiateUpload(config.vaultId, file.name, file.size, file.type || 'application/octet-stream');
|
|
7990
|
+
// Show uploading state
|
|
7991
|
+
this.setState({
|
|
7992
|
+
view: 'uploading',
|
|
7993
|
+
file,
|
|
7994
|
+
ingotId,
|
|
7995
|
+
requestId,
|
|
7996
|
+
progress: 0,
|
|
7997
|
+
bytesUploaded: 0,
|
|
7998
|
+
});
|
|
7999
|
+
// Upload file using tus (resumable upload)
|
|
8000
|
+
await this.uploadWithTus(file, forgeUrl, ingotId, requestId);
|
|
8001
|
+
// Run ceremony animation
|
|
8002
|
+
await this.runCeremony(file, ingotId, requestId);
|
|
8003
|
+
// Complete
|
|
8004
|
+
const now = new Date();
|
|
8005
|
+
const uploadTime = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')} ${String(now.getUTCHours()).padStart(2, '0')}:${String(now.getUTCMinutes()).padStart(2, '0')}:${String(now.getUTCSeconds()).padStart(2, '0')} UTC`;
|
|
8006
|
+
const result = {
|
|
8007
|
+
ingotId,
|
|
8008
|
+
vaultId: config.vaultId,
|
|
8009
|
+
filename: file.name,
|
|
8010
|
+
sizeBytes: file.size,
|
|
8011
|
+
uploadTime,
|
|
8012
|
+
};
|
|
8013
|
+
// Notify complete phase
|
|
8014
|
+
this.callbacks.onProgress?.({
|
|
8015
|
+
bytesUploaded: file.size,
|
|
8016
|
+
bytesTotal: file.size,
|
|
8017
|
+
percentage: 100,
|
|
8018
|
+
phase: 'complete',
|
|
8019
|
+
});
|
|
8020
|
+
this.setState({ view: 'complete', result, config });
|
|
8021
|
+
// Notify success
|
|
8022
|
+
this.callbacks.onSuccess(result);
|
|
8023
|
+
}
|
|
8024
|
+
catch (error) {
|
|
8025
|
+
this.handleApiError(error);
|
|
8026
|
+
}
|
|
8027
|
+
}
|
|
8028
|
+
async uploadWithTus(file, forgeUrl, ingotId, requestId) {
|
|
8029
|
+
// Simple XHR upload with progress (tus-like behavior)
|
|
8030
|
+
// Timeout: 10 minutes for large file uploads
|
|
8031
|
+
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8032
|
+
return new Promise((resolve, reject) => {
|
|
8033
|
+
const xhr = new XMLHttpRequest();
|
|
8034
|
+
xhr.timeout = UPLOAD_TIMEOUT_MS;
|
|
8035
|
+
xhr.upload.onprogress = (e) => {
|
|
8036
|
+
if (e.lengthComputable) {
|
|
8037
|
+
const progress = Math.round((e.loaded / e.total) * 100);
|
|
8038
|
+
this.setState({
|
|
8039
|
+
view: 'uploading',
|
|
8040
|
+
file,
|
|
8041
|
+
ingotId,
|
|
8042
|
+
requestId,
|
|
8043
|
+
progress,
|
|
8044
|
+
bytesUploaded: e.loaded,
|
|
8045
|
+
});
|
|
8046
|
+
this.callbacks.onProgress?.({
|
|
8047
|
+
bytesUploaded: e.loaded,
|
|
8048
|
+
bytesTotal: e.total,
|
|
8049
|
+
percentage: progress,
|
|
8050
|
+
phase: 'uploading',
|
|
8051
|
+
});
|
|
8052
|
+
}
|
|
8053
|
+
};
|
|
8054
|
+
xhr.onload = () => {
|
|
8055
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
8056
|
+
resolve();
|
|
8057
|
+
}
|
|
8058
|
+
else {
|
|
8059
|
+
reject(new Error(`Upload failed with status ${xhr.status}`));
|
|
8060
|
+
}
|
|
8061
|
+
};
|
|
8062
|
+
xhr.onerror = () => reject(new Error('Upload failed'));
|
|
8063
|
+
xhr.ontimeout = () => reject(new Error('Upload timed out'));
|
|
8064
|
+
xhr.open('POST', forgeUrl);
|
|
8065
|
+
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
|
8066
|
+
xhr.send(file);
|
|
8067
|
+
});
|
|
8068
|
+
}
|
|
8069
|
+
async runCeremony(file, ingotId, requestId) {
|
|
8070
|
+
for (let i = 0; i < CEREMONY_STEPS.length; i++) {
|
|
8071
|
+
this.setState({
|
|
8072
|
+
view: 'ceremony',
|
|
8073
|
+
file,
|
|
8074
|
+
ingotId,
|
|
8075
|
+
requestId,
|
|
8076
|
+
step: i,
|
|
8077
|
+
complete: false,
|
|
8078
|
+
});
|
|
8079
|
+
this.callbacks.onProgress?.({
|
|
8080
|
+
bytesUploaded: file.size,
|
|
8081
|
+
bytesTotal: file.size,
|
|
8082
|
+
percentage: 100,
|
|
8083
|
+
phase: 'ceremony',
|
|
8084
|
+
});
|
|
8085
|
+
await new Promise(resolve => setTimeout(resolve, CEREMONY_STEPS[i].duration));
|
|
8086
|
+
}
|
|
8087
|
+
// Mark complete
|
|
8088
|
+
this.setState({
|
|
8089
|
+
view: 'ceremony',
|
|
8090
|
+
file,
|
|
8091
|
+
ingotId,
|
|
8092
|
+
requestId,
|
|
8093
|
+
step: CEREMONY_STEPS.length,
|
|
8094
|
+
complete: true,
|
|
8095
|
+
});
|
|
8096
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
8097
|
+
}
|
|
8098
|
+
handleApiError(error) {
|
|
8099
|
+
if (error instanceof UploadApiError) {
|
|
8100
|
+
// Handle specific HTTP status codes
|
|
8101
|
+
if (error.httpStatus === 404) {
|
|
8102
|
+
this.setState({
|
|
8103
|
+
view: 'error',
|
|
8104
|
+
message: 'The requested upload endpoint could not be located. This may occur if the upload link has expired, been disabled, or was entered incorrectly.',
|
|
8105
|
+
code: 'NOT_FOUND',
|
|
8106
|
+
httpStatus: 404,
|
|
8107
|
+
});
|
|
8108
|
+
}
|
|
8109
|
+
else if (error.httpStatus === 402) {
|
|
8110
|
+
this.setState({
|
|
8111
|
+
view: 'error',
|
|
8112
|
+
message: 'This upload endpoint has been temporarily disabled due to an account billing issue. Please contact the organization\'s administrator.',
|
|
8113
|
+
code: 'PAYMENT_REQUIRED',
|
|
8114
|
+
httpStatus: 402,
|
|
8115
|
+
});
|
|
8116
|
+
}
|
|
8117
|
+
else {
|
|
8118
|
+
this.setState({
|
|
8119
|
+
view: 'error',
|
|
8120
|
+
message: error.message,
|
|
8121
|
+
code: error.code,
|
|
8122
|
+
httpStatus: error.httpStatus,
|
|
8123
|
+
});
|
|
8124
|
+
}
|
|
8125
|
+
this.callbacks.onError(error);
|
|
8126
|
+
}
|
|
8127
|
+
else if (error instanceof Error) {
|
|
8128
|
+
this.setState({
|
|
8129
|
+
view: 'error',
|
|
8130
|
+
message: error.message,
|
|
8131
|
+
code: 'UNKNOWN_ERROR',
|
|
8132
|
+
});
|
|
8133
|
+
this.callbacks.onError(error);
|
|
8134
|
+
}
|
|
8135
|
+
else {
|
|
8136
|
+
const err = new Error('An unexpected error occurred');
|
|
8137
|
+
this.setState({
|
|
8138
|
+
view: 'error',
|
|
8139
|
+
message: err.message,
|
|
8140
|
+
code: 'UNKNOWN_ERROR',
|
|
8141
|
+
});
|
|
8142
|
+
this.callbacks.onError(err);
|
|
8143
|
+
}
|
|
8144
|
+
}
|
|
8145
|
+
escapeHtml(str) {
|
|
8146
|
+
const div = document.createElement('div');
|
|
8147
|
+
div.textContent = str;
|
|
8148
|
+
return div.innerHTML;
|
|
8149
|
+
}
|
|
8150
|
+
}
|
|
8151
|
+
|
|
8152
|
+
/* global Element */
|
|
5373
8153
|
/**
|
|
5374
|
-
*
|
|
8154
|
+
* Vault Upload Module
|
|
5375
8155
|
*
|
|
5376
|
-
*
|
|
5377
|
-
*
|
|
8156
|
+
* Embedded upload widget for vaults with public upload enabled.
|
|
8157
|
+
*
|
|
8158
|
+
* @example Dialog mode (immediate)
|
|
8159
|
+
* const result = await sv.vaults.upload({ vaultId: 'vlt_abc123' });
|
|
8160
|
+
*
|
|
8161
|
+
* @example Dialog mode (attached to clicks)
|
|
8162
|
+
* sv.vaults.upload.attach('.upload-btn', { vaultId: 'vlt_abc123' });
|
|
8163
|
+
*
|
|
8164
|
+
* @example Inline mode
|
|
8165
|
+
* const result = await sv.vaults.upload({
|
|
8166
|
+
* vaultId: 'vlt_abc123',
|
|
8167
|
+
* target: '#upload-container'
|
|
8168
|
+
* });
|
|
5378
8169
|
*/
|
|
5379
|
-
class
|
|
5380
|
-
constructor(
|
|
5381
|
-
this.
|
|
8170
|
+
class VaultUploadModule {
|
|
8171
|
+
constructor(config) {
|
|
8172
|
+
this.renderer = null;
|
|
8173
|
+
this.attachedElements = new Map();
|
|
8174
|
+
this.config = config;
|
|
8175
|
+
this.api = new UploadApi(config);
|
|
5382
8176
|
}
|
|
5383
8177
|
/**
|
|
5384
|
-
*
|
|
8178
|
+
* Upload a file to a vault.
|
|
5385
8179
|
*
|
|
5386
|
-
*
|
|
5387
|
-
*
|
|
5388
|
-
*
|
|
5389
|
-
*
|
|
5390
|
-
*
|
|
8180
|
+
* - Without `target`: Opens a dialog (modal)
|
|
8181
|
+
* - With `target`: Renders inline into the specified element
|
|
8182
|
+
*
|
|
8183
|
+
* @example Dialog mode
|
|
8184
|
+
* const result = await sv.vaults.upload({ vaultId: 'vlt_abc123' });
|
|
8185
|
+
* console.log('Uploaded:', result.ingotId);
|
|
8186
|
+
*
|
|
8187
|
+
* @example Inline mode
|
|
8188
|
+
* const result = await sv.vaults.upload({
|
|
8189
|
+
* vaultId: 'vlt_abc123',
|
|
8190
|
+
* target: '#upload-container'
|
|
5391
8191
|
* });
|
|
5392
|
-
* console.log(spark.url);
|
|
5393
8192
|
*/
|
|
5394
|
-
async
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
8193
|
+
async upload(options) {
|
|
8194
|
+
if (!options.vaultId) {
|
|
8195
|
+
throw new ValidationError('vaultId is required');
|
|
8196
|
+
}
|
|
8197
|
+
// Close any existing renderer
|
|
8198
|
+
if (this.renderer) {
|
|
8199
|
+
this.renderer.close();
|
|
8200
|
+
}
|
|
8201
|
+
const isInline = !!options.target;
|
|
8202
|
+
const container = isInline
|
|
8203
|
+
? this.createInlineContainer(options.target)
|
|
8204
|
+
: new UploadModalContainer();
|
|
8205
|
+
// Merge global config defaults with per-call options
|
|
8206
|
+
const mergedOptions = {
|
|
8207
|
+
...options,
|
|
8208
|
+
backdropBlur: options.backdropBlur ?? this.config.backdropBlur,
|
|
5410
8209
|
};
|
|
8210
|
+
return new Promise((resolve, reject) => {
|
|
8211
|
+
this.renderer = new UploadRenderer(container, this.api, mergedOptions, {
|
|
8212
|
+
onSuccess: (result) => {
|
|
8213
|
+
options.onSuccess?.(result);
|
|
8214
|
+
resolve(result);
|
|
8215
|
+
},
|
|
8216
|
+
onError: (error) => {
|
|
8217
|
+
options.onError?.(error);
|
|
8218
|
+
reject(error);
|
|
8219
|
+
},
|
|
8220
|
+
onCancel: () => {
|
|
8221
|
+
const error = new UserCancelledError();
|
|
8222
|
+
options.onCancel?.();
|
|
8223
|
+
reject(error);
|
|
8224
|
+
},
|
|
8225
|
+
onProgress: options.onProgress,
|
|
8226
|
+
});
|
|
8227
|
+
this.renderer.start().catch((error) => {
|
|
8228
|
+
options.onError?.(error);
|
|
8229
|
+
reject(error);
|
|
8230
|
+
});
|
|
8231
|
+
});
|
|
5411
8232
|
}
|
|
5412
8233
|
/**
|
|
5413
|
-
*
|
|
5414
|
-
*
|
|
8234
|
+
* Attach upload functionality to element clicks.
|
|
8235
|
+
* When any matching element is clicked, opens the upload dialog.
|
|
8236
|
+
*
|
|
8237
|
+
* @param selector - CSS selector for elements to attach to
|
|
8238
|
+
* @param options - Upload options including vaultId and callbacks
|
|
8239
|
+
* @returns Cleanup function to remove event listeners
|
|
5415
8240
|
*
|
|
5416
8241
|
* @example
|
|
5417
|
-
* const
|
|
5418
|
-
*
|
|
8242
|
+
* const cleanup = sv.vaults.upload.attach('.upload-btn', {
|
|
8243
|
+
* vaultId: 'vlt_abc123',
|
|
8244
|
+
* onSuccess: (result) => console.log('Uploaded:', result.ingotId)
|
|
8245
|
+
* });
|
|
8246
|
+
*
|
|
8247
|
+
* // Later, remove listeners
|
|
8248
|
+
* cleanup();
|
|
5419
8249
|
*/
|
|
5420
|
-
|
|
5421
|
-
if (!
|
|
5422
|
-
throw new ValidationError('
|
|
8250
|
+
attach(selector, options) {
|
|
8251
|
+
if (!options.vaultId) {
|
|
8252
|
+
throw new ValidationError('vaultId is required');
|
|
5423
8253
|
}
|
|
5424
|
-
|
|
5425
|
-
const
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
8254
|
+
let observer = null;
|
|
8255
|
+
const handleClick = async (event) => {
|
|
8256
|
+
event.preventDefault();
|
|
8257
|
+
try {
|
|
8258
|
+
await this.upload({
|
|
8259
|
+
vaultId: options.vaultId,
|
|
8260
|
+
onSuccess: options.onSuccess,
|
|
8261
|
+
onError: options.onError,
|
|
8262
|
+
onCancel: options.onCancel,
|
|
8263
|
+
onProgress: options.onProgress,
|
|
8264
|
+
});
|
|
8265
|
+
}
|
|
8266
|
+
catch {
|
|
8267
|
+
// Error already handled by callbacks or will use server redirect
|
|
8268
|
+
}
|
|
8269
|
+
};
|
|
8270
|
+
const attachToElements = () => {
|
|
8271
|
+
const elements = document.querySelectorAll(selector);
|
|
8272
|
+
elements.forEach((element) => {
|
|
8273
|
+
element.addEventListener('click', handleClick);
|
|
8274
|
+
this.attachedElements.set(element, () => {
|
|
8275
|
+
element.removeEventListener('click', handleClick);
|
|
8276
|
+
});
|
|
8277
|
+
});
|
|
8278
|
+
// Watch for dynamically added elements
|
|
8279
|
+
observer = new MutationObserver((mutations) => {
|
|
8280
|
+
mutations.forEach((mutation) => {
|
|
8281
|
+
mutation.addedNodes.forEach((node) => {
|
|
8282
|
+
if (node instanceof Element) {
|
|
8283
|
+
if (node.matches(selector)) {
|
|
8284
|
+
node.addEventListener('click', handleClick);
|
|
8285
|
+
this.attachedElements.set(node, () => {
|
|
8286
|
+
node.removeEventListener('click', handleClick);
|
|
8287
|
+
});
|
|
8288
|
+
}
|
|
8289
|
+
// Check descendants
|
|
8290
|
+
node.querySelectorAll(selector).forEach((el) => {
|
|
8291
|
+
if (!this.attachedElements.has(el)) {
|
|
8292
|
+
el.addEventListener('click', handleClick);
|
|
8293
|
+
this.attachedElements.set(el, () => {
|
|
8294
|
+
el.removeEventListener('click', handleClick);
|
|
8295
|
+
});
|
|
8296
|
+
}
|
|
8297
|
+
});
|
|
8298
|
+
}
|
|
8299
|
+
});
|
|
8300
|
+
});
|
|
8301
|
+
});
|
|
8302
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
8303
|
+
};
|
|
8304
|
+
// Defer attachment until DOM is ready
|
|
8305
|
+
onDomReady(attachToElements);
|
|
8306
|
+
// Return cleanup function
|
|
8307
|
+
return () => {
|
|
8308
|
+
observer?.disconnect();
|
|
8309
|
+
this.attachedElements.forEach((cleanup) => cleanup());
|
|
8310
|
+
this.attachedElements.clear();
|
|
5435
8311
|
};
|
|
5436
8312
|
}
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
8313
|
+
/**
|
|
8314
|
+
* Close the upload dialog/inline UI if open.
|
|
8315
|
+
*/
|
|
8316
|
+
close() {
|
|
8317
|
+
if (this.renderer) {
|
|
8318
|
+
this.renderer.close();
|
|
8319
|
+
this.renderer = null;
|
|
5440
8320
|
}
|
|
5441
|
-
|
|
5442
|
-
|
|
8321
|
+
}
|
|
8322
|
+
/**
|
|
8323
|
+
* Resolve target to an HTMLElement and create inline container.
|
|
8324
|
+
*/
|
|
8325
|
+
createInlineContainer(target) {
|
|
8326
|
+
let element;
|
|
8327
|
+
if (typeof target === 'string') {
|
|
8328
|
+
const found = document.querySelector(target);
|
|
8329
|
+
if (!found || !(found instanceof HTMLElement)) {
|
|
8330
|
+
throw new ValidationError(`Target selector "${target}" did not match any element`);
|
|
8331
|
+
}
|
|
8332
|
+
element = found;
|
|
5443
8333
|
}
|
|
5444
|
-
if (
|
|
5445
|
-
|
|
8334
|
+
else if (target instanceof HTMLElement) {
|
|
8335
|
+
element = target;
|
|
5446
8336
|
}
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
if (typeof payload === 'string') {
|
|
5450
|
-
return payload;
|
|
8337
|
+
else {
|
|
8338
|
+
throw new ValidationError('Target must be a CSS selector string or HTMLElement');
|
|
5451
8339
|
}
|
|
5452
|
-
|
|
5453
|
-
return base64urlEncode(payload);
|
|
8340
|
+
return new UploadInlineContainer(element);
|
|
5454
8341
|
}
|
|
5455
|
-
|
|
5456
|
-
|
|
8342
|
+
// Deprecated methods for backwards compatibility
|
|
8343
|
+
/**
|
|
8344
|
+
* @deprecated Use `upload()` instead. Will be removed in v2.0.
|
|
8345
|
+
*/
|
|
8346
|
+
async pop(options) {
|
|
8347
|
+
return this.upload(options);
|
|
8348
|
+
}
|
|
8349
|
+
/**
|
|
8350
|
+
* @deprecated Use `upload({ target })` instead. Will be removed in v2.0.
|
|
8351
|
+
*/
|
|
8352
|
+
async render(options) {
|
|
8353
|
+
return this.upload(options);
|
|
5457
8354
|
}
|
|
5458
8355
|
}
|
|
5459
8356
|
|
|
@@ -5464,9 +8361,14 @@ class SparksModule {
|
|
|
5464
8361
|
* Uses Triple Zero-Trust encryption (SVMK + AMK + VMK).
|
|
5465
8362
|
*/
|
|
5466
8363
|
class VaultsModule {
|
|
5467
|
-
constructor(
|
|
5468
|
-
// Config parameter kept for API consistency; may be used for versioning/feature flags
|
|
8364
|
+
constructor(config, http) {
|
|
5469
8365
|
this.http = http;
|
|
8366
|
+
this.uploadModule = new VaultUploadModule(config);
|
|
8367
|
+
// Create callable that also has attach/close methods
|
|
8368
|
+
const uploadFn = ((options) => this.uploadModule.upload(options));
|
|
8369
|
+
uploadFn.attach = (selector, options) => this.uploadModule.attach(selector, options);
|
|
8370
|
+
uploadFn.close = () => this.uploadModule.close();
|
|
8371
|
+
this.upload = uploadFn;
|
|
5470
8372
|
}
|
|
5471
8373
|
/**
|
|
5472
8374
|
* Create a new encrypted vault.
|
|
@@ -5640,119 +8542,6 @@ class VaultsModule {
|
|
|
5640
8542
|
}
|
|
5641
8543
|
}
|
|
5642
8544
|
|
|
5643
|
-
/**
|
|
5644
|
-
* RNG Module
|
|
5645
|
-
*
|
|
5646
|
-
* Generate cryptographically secure random numbers.
|
|
5647
|
-
* Uses hybrid entropy from AWS KMS HSM + local CSPRNG.
|
|
5648
|
-
*/
|
|
5649
|
-
const VALID_RNG_FORMATS = [
|
|
5650
|
-
'hex', 'base64', 'base64url', 'alphanumeric',
|
|
5651
|
-
'alphanumeric-mixed', 'password', 'numeric', 'uuid', 'bytes'
|
|
5652
|
-
];
|
|
5653
|
-
function isRNGFormat(value) {
|
|
5654
|
-
return VALID_RNG_FORMATS.includes(value);
|
|
5655
|
-
}
|
|
5656
|
-
class RNGModule {
|
|
5657
|
-
constructor(http) {
|
|
5658
|
-
this.http = http;
|
|
5659
|
-
}
|
|
5660
|
-
/**
|
|
5661
|
-
* Generate cryptographically secure random bytes.
|
|
5662
|
-
*
|
|
5663
|
-
* @example
|
|
5664
|
-
* const result = await sv.rng.generate({ bytes: 32, format: 'hex' });
|
|
5665
|
-
* console.log(result.value);
|
|
5666
|
-
*/
|
|
5667
|
-
async generate(options) {
|
|
5668
|
-
this.validateOptions(options);
|
|
5669
|
-
const response = await this.http.post('/v1/apps/rng/generate', {
|
|
5670
|
-
num_bytes: options.bytes,
|
|
5671
|
-
format: options.format ?? 'base64url',
|
|
5672
|
-
});
|
|
5673
|
-
const responseFormat = response.data.format ?? 'base64url';
|
|
5674
|
-
if (!isRNGFormat(responseFormat)) {
|
|
5675
|
-
throw new ValidationError(`Server returned invalid format: ${responseFormat}`);
|
|
5676
|
-
}
|
|
5677
|
-
return {
|
|
5678
|
-
// Use centralized base64url utility (CLAUDE.md DRY)
|
|
5679
|
-
value: responseFormat === 'bytes'
|
|
5680
|
-
? base64urlToBytes(response.data.random_bytes_b64)
|
|
5681
|
-
: response.data.random_bytes_b64,
|
|
5682
|
-
bytes: response.data.num_bytes,
|
|
5683
|
-
format: responseFormat,
|
|
5684
|
-
referenceId: response.data.reference_id,
|
|
5685
|
-
};
|
|
5686
|
-
}
|
|
5687
|
-
/**
|
|
5688
|
-
* Generate a random UUID v4.
|
|
5689
|
-
*
|
|
5690
|
-
* @example
|
|
5691
|
-
* const uuid = await sv.rng.uuid();
|
|
5692
|
-
* console.log(uuid); // 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
|
5693
|
-
*/
|
|
5694
|
-
async uuid() {
|
|
5695
|
-
const result = await this.generate({ bytes: 16, format: 'uuid' });
|
|
5696
|
-
if (typeof result.value !== 'string') {
|
|
5697
|
-
throw new ValidationError('Expected string value for uuid format');
|
|
5698
|
-
}
|
|
5699
|
-
return result.value;
|
|
5700
|
-
}
|
|
5701
|
-
/**
|
|
5702
|
-
* Generate a random hex string.
|
|
5703
|
-
*
|
|
5704
|
-
* @example
|
|
5705
|
-
* const hex = await sv.rng.hex(16);
|
|
5706
|
-
* console.log(hex); // '1a2b3c4d5e6f7890...'
|
|
5707
|
-
*/
|
|
5708
|
-
async hex(bytes) {
|
|
5709
|
-
const result = await this.generate({ bytes, format: 'hex' });
|
|
5710
|
-
if (typeof result.value !== 'string') {
|
|
5711
|
-
throw new ValidationError('Expected string value for hex format');
|
|
5712
|
-
}
|
|
5713
|
-
return result.value;
|
|
5714
|
-
}
|
|
5715
|
-
/**
|
|
5716
|
-
* Generate a random alphanumeric string.
|
|
5717
|
-
*
|
|
5718
|
-
* @example
|
|
5719
|
-
* const code = await sv.rng.alphanumeric(8);
|
|
5720
|
-
* console.log(code); // 'A1B2C3D4'
|
|
5721
|
-
*/
|
|
5722
|
-
async alphanumeric(bytes) {
|
|
5723
|
-
const result = await this.generate({ bytes, format: 'alphanumeric-mixed' });
|
|
5724
|
-
if (typeof result.value !== 'string') {
|
|
5725
|
-
throw new ValidationError('Expected string value for alphanumeric format');
|
|
5726
|
-
}
|
|
5727
|
-
return result.value;
|
|
5728
|
-
}
|
|
5729
|
-
/**
|
|
5730
|
-
* Generate a random password with special characters.
|
|
5731
|
-
*
|
|
5732
|
-
* @example
|
|
5733
|
-
* const password = await sv.rng.password(16);
|
|
5734
|
-
* console.log(password); // 'aB3$xY7!mN9@pQ2#'
|
|
5735
|
-
*/
|
|
5736
|
-
async password(bytes) {
|
|
5737
|
-
const result = await this.generate({ bytes, format: 'password' });
|
|
5738
|
-
if (typeof result.value !== 'string') {
|
|
5739
|
-
throw new ValidationError('Expected string value for password format');
|
|
5740
|
-
}
|
|
5741
|
-
return result.value;
|
|
5742
|
-
}
|
|
5743
|
-
validateOptions(options) {
|
|
5744
|
-
if (!options.bytes || typeof options.bytes !== 'number') {
|
|
5745
|
-
throw new ValidationError('bytes is required and must be a number');
|
|
5746
|
-
}
|
|
5747
|
-
if (options.bytes < 1 || options.bytes > 1024) {
|
|
5748
|
-
throw new ValidationError('bytes must be between 1 and 1024');
|
|
5749
|
-
}
|
|
5750
|
-
if (options.format && !isRNGFormat(options.format)) {
|
|
5751
|
-
throw new ValidationError(`Invalid format. Must be one of: ${VALID_RNG_FORMATS.join(', ')}`);
|
|
5752
|
-
}
|
|
5753
|
-
}
|
|
5754
|
-
}
|
|
5755
|
-
|
|
5756
8545
|
/**
|
|
5757
8546
|
* SparkVault Debug Logger
|
|
5758
8547
|
*
|
|
@@ -5840,412 +8629,95 @@ const logger = {
|
|
|
5840
8629
|
/**
|
|
5841
8630
|
* SparkVault Auto-Initialization
|
|
5842
8631
|
*
|
|
5843
|
-
*
|
|
8632
|
+
* Auto-initializes the SDK from script tag data attributes.
|
|
5844
8633
|
*
|
|
5845
8634
|
* @example
|
|
5846
8635
|
* ```html
|
|
5847
8636
|
* <script
|
|
5848
|
-
* async
|
|
5849
8637
|
* src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"
|
|
5850
8638
|
* data-account-id="acc_your_account_id"
|
|
5851
|
-
* data-attach-selector=".js-sparkvault-auth"
|
|
5852
|
-
* data-success-url="https://example.com/auth/verify-token"
|
|
5853
|
-
* data-error-function="handleSparkVaultError"
|
|
5854
|
-
* data-debug="true"
|
|
5855
8639
|
* ></script>
|
|
8640
|
+
*
|
|
8641
|
+
* <script>
|
|
8642
|
+
* // SDK is ready - just use it
|
|
8643
|
+
* SparkVault.identity.attach('.login-btn', {
|
|
8644
|
+
* onSuccess: (result) => console.log('Verified:', result.identity)
|
|
8645
|
+
* });
|
|
8646
|
+
* </script>
|
|
5856
8647
|
* ```
|
|
5857
8648
|
*
|
|
5858
8649
|
* Supported attributes:
|
|
5859
8650
|
* - data-account-id: Account ID (required for auto-init)
|
|
5860
|
-
* - data-attach-selector: CSS selector for elements to attach click handlers
|
|
5861
|
-
* - data-success-url: URL to POST { token, identity } on successful verification
|
|
5862
|
-
* - data-success-function: Global function name to call on success (receives { token, identity, identityType })
|
|
5863
|
-
* - data-error-url: URL to redirect to on error (appends ?error=message)
|
|
5864
|
-
* - data-error-function: Global function name to call on error (receives Error object)
|
|
5865
8651
|
* - data-debug: Set to "true" to enable verbose console logging
|
|
5866
8652
|
*/
|
|
5867
|
-
const state = {
|
|
5868
|
-
initialized: false,
|
|
5869
|
-
instance: null,
|
|
5870
|
-
config: null,
|
|
5871
|
-
observer: null,
|
|
5872
|
-
isAuthenticating: false,
|
|
5873
|
-
};
|
|
5874
|
-
/**
|
|
5875
|
-
* Get the current script element
|
|
5876
|
-
*/
|
|
5877
|
-
function getCurrentScript() {
|
|
5878
|
-
// Try document.currentScript first (works during initial execution)
|
|
5879
|
-
if (document.currentScript instanceof HTMLScriptElement) {
|
|
5880
|
-
return document.currentScript;
|
|
5881
|
-
}
|
|
5882
|
-
// Fallback: find script by src pattern
|
|
5883
|
-
const scripts = document.querySelectorAll('script[src*="sparkvault"]');
|
|
5884
|
-
for (const script of scripts) {
|
|
5885
|
-
if (script.dataset.accountId) {
|
|
5886
|
-
return script;
|
|
5887
|
-
}
|
|
5888
|
-
}
|
|
5889
|
-
return scripts[0] || null;
|
|
5890
|
-
}
|
|
5891
|
-
/**
|
|
5892
|
-
* Parse configuration from script tag data attributes
|
|
5893
|
-
*/
|
|
5894
|
-
function parseConfig(script) {
|
|
5895
|
-
const timeoutStr = script.dataset.timeout;
|
|
5896
|
-
return {
|
|
5897
|
-
accountId: script.dataset.accountId || null,
|
|
5898
|
-
attachSelector: script.dataset.attachSelector || null,
|
|
5899
|
-
successUrl: script.dataset.successUrl || null,
|
|
5900
|
-
successFunction: script.dataset.successFunction || null,
|
|
5901
|
-
errorUrl: script.dataset.errorUrl || null,
|
|
5902
|
-
errorFunction: script.dataset.errorFunction || null,
|
|
5903
|
-
debug: script.dataset.debug === 'true',
|
|
5904
|
-
preloadConfig: script.dataset.preloadConfig !== 'false', // default: true
|
|
5905
|
-
timeout: timeoutStr ? parseInt(timeoutStr, 10) : 30000,
|
|
5906
|
-
};
|
|
5907
|
-
}
|
|
5908
|
-
/**
|
|
5909
|
-
* Safely resolve a global function by name (no eval)
|
|
5910
|
-
*/
|
|
5911
|
-
function resolveFunction(name) {
|
|
5912
|
-
if (!name || typeof name !== 'string') {
|
|
5913
|
-
return null;
|
|
5914
|
-
}
|
|
5915
|
-
// Split by dots for nested properties (e.g., "MyApp.auth.onSuccess")
|
|
5916
|
-
const parts = name.split('.');
|
|
5917
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5918
|
-
let current = window;
|
|
5919
|
-
for (const part of parts) {
|
|
5920
|
-
if (current && typeof current === 'object' && part in current) {
|
|
5921
|
-
current = current[part];
|
|
5922
|
-
}
|
|
5923
|
-
else {
|
|
5924
|
-
logger.warn(`Function "${name}" not found on window`);
|
|
5925
|
-
return null;
|
|
5926
|
-
}
|
|
5927
|
-
}
|
|
5928
|
-
if (typeof current === 'function') {
|
|
5929
|
-
return current;
|
|
5930
|
-
}
|
|
5931
|
-
logger.warn(`"${name}" is not a function`);
|
|
5932
|
-
return null;
|
|
5933
|
-
}
|
|
5934
|
-
/**
|
|
5935
|
-
* Handle successful verification
|
|
5936
|
-
*/
|
|
5937
|
-
async function handleSuccess(result) {
|
|
5938
|
-
const config = state.config;
|
|
5939
|
-
if (!config)
|
|
5940
|
-
return;
|
|
5941
|
-
logger.info('Verification successful', {
|
|
5942
|
-
identity: result.identity,
|
|
5943
|
-
identityType: result.identityType,
|
|
5944
|
-
hasToken: !!result.token,
|
|
5945
|
-
});
|
|
5946
|
-
// Call success function if specified
|
|
5947
|
-
if (config.successFunction) {
|
|
5948
|
-
const fn = resolveFunction(config.successFunction);
|
|
5949
|
-
if (fn) {
|
|
5950
|
-
logger.debug(`Calling success function: ${config.successFunction}`);
|
|
5951
|
-
try {
|
|
5952
|
-
fn(result);
|
|
5953
|
-
}
|
|
5954
|
-
catch (err) {
|
|
5955
|
-
logger.error('Error in success function:', err);
|
|
5956
|
-
}
|
|
5957
|
-
}
|
|
5958
|
-
}
|
|
5959
|
-
// POST to success URL if specified
|
|
5960
|
-
if (config.successUrl) {
|
|
5961
|
-
logger.debug(`POSTing to success URL: ${config.successUrl}`);
|
|
5962
|
-
try {
|
|
5963
|
-
const response = await fetch(config.successUrl, {
|
|
5964
|
-
method: 'POST',
|
|
5965
|
-
headers: {
|
|
5966
|
-
'Content-Type': 'application/json',
|
|
5967
|
-
'Accept': 'application/json',
|
|
5968
|
-
},
|
|
5969
|
-
credentials: 'include',
|
|
5970
|
-
body: JSON.stringify({
|
|
5971
|
-
token: result.token,
|
|
5972
|
-
identity: result.identity,
|
|
5973
|
-
identityType: result.identityType,
|
|
5974
|
-
}),
|
|
5975
|
-
});
|
|
5976
|
-
const data = await response.json();
|
|
5977
|
-
logger.debug('Success URL response:', data);
|
|
5978
|
-
// If server returns a redirect URL, follow it
|
|
5979
|
-
if (data.redirectUrl || data.redirect_url) {
|
|
5980
|
-
const redirectUrl = data.redirectUrl || data.redirect_url;
|
|
5981
|
-
logger.info(`Redirecting to: ${redirectUrl}`);
|
|
5982
|
-
window.location.href = redirectUrl;
|
|
5983
|
-
}
|
|
5984
|
-
else if (data.reload === true) {
|
|
5985
|
-
logger.info('Reloading page');
|
|
5986
|
-
window.location.reload();
|
|
5987
|
-
}
|
|
5988
|
-
}
|
|
5989
|
-
catch (err) {
|
|
5990
|
-
logger.error('Failed to POST to success URL:', err);
|
|
5991
|
-
handleError(err instanceof Error ? err : new Error(String(err)));
|
|
5992
|
-
}
|
|
5993
|
-
}
|
|
5994
|
-
}
|
|
5995
|
-
/**
|
|
5996
|
-
* Handle verification error
|
|
5997
|
-
*/
|
|
5998
|
-
function handleError(error) {
|
|
5999
|
-
const config = state.config;
|
|
6000
|
-
if (!config)
|
|
6001
|
-
return;
|
|
6002
|
-
logger.error('Verification error:', error.message);
|
|
6003
|
-
// Call error function if specified
|
|
6004
|
-
if (config.errorFunction) {
|
|
6005
|
-
const fn = resolveFunction(config.errorFunction);
|
|
6006
|
-
if (fn) {
|
|
6007
|
-
logger.debug(`Calling error function: ${config.errorFunction}`);
|
|
6008
|
-
try {
|
|
6009
|
-
fn(error);
|
|
6010
|
-
}
|
|
6011
|
-
catch (err) {
|
|
6012
|
-
logger.error('Error in error function:', err);
|
|
6013
|
-
}
|
|
6014
|
-
}
|
|
6015
|
-
}
|
|
6016
|
-
// Redirect to error URL if specified
|
|
6017
|
-
if (config.errorUrl) {
|
|
6018
|
-
const url = new URL(config.errorUrl, window.location.origin);
|
|
6019
|
-
url.searchParams.set('error', error.message);
|
|
6020
|
-
logger.debug(`Redirecting to error URL: ${url.toString()}`);
|
|
6021
|
-
window.location.href = url.toString();
|
|
6022
|
-
}
|
|
6023
|
-
}
|
|
6024
|
-
/**
|
|
6025
|
-
* Start the verification flow (popup modal with configured handlers)
|
|
6026
|
-
*/
|
|
6027
|
-
async function pop() {
|
|
6028
|
-
if (!state.instance) {
|
|
6029
|
-
logger.error('SDK not initialized');
|
|
6030
|
-
return;
|
|
6031
|
-
}
|
|
6032
|
-
if (state.isAuthenticating) {
|
|
6033
|
-
logger.debug('Already authenticating, skipping');
|
|
6034
|
-
return;
|
|
6035
|
-
}
|
|
6036
|
-
state.isAuthenticating = true;
|
|
6037
|
-
logger.info('Starting verification...');
|
|
6038
|
-
try {
|
|
6039
|
-
const result = await state.instance.identity.pop();
|
|
6040
|
-
// User cancelled
|
|
6041
|
-
if (!result) {
|
|
6042
|
-
logger.info('User cancelled verification');
|
|
6043
|
-
state.isAuthenticating = false;
|
|
6044
|
-
return;
|
|
6045
|
-
}
|
|
6046
|
-
await handleSuccess(result);
|
|
6047
|
-
}
|
|
6048
|
-
catch (err) {
|
|
6049
|
-
// UserCancelledError is not an error
|
|
6050
|
-
if (err && typeof err === 'object' && 'name' in err) {
|
|
6051
|
-
const error = err;
|
|
6052
|
-
if (error.name === 'UserCancelledError' || error.code === 'user_cancelled') {
|
|
6053
|
-
logger.info('User cancelled verification');
|
|
6054
|
-
state.isAuthenticating = false;
|
|
6055
|
-
return;
|
|
6056
|
-
}
|
|
6057
|
-
}
|
|
6058
|
-
handleError(err instanceof Error ? err : new Error(String(err)));
|
|
6059
|
-
}
|
|
6060
|
-
finally {
|
|
6061
|
-
state.isAuthenticating = false;
|
|
6062
|
-
}
|
|
6063
|
-
}
|
|
6064
|
-
/**
|
|
6065
|
-
* Attach click handlers to elements matching the selector
|
|
6066
|
-
*/
|
|
6067
|
-
function attachHandlers() {
|
|
6068
|
-
const config = state.config;
|
|
6069
|
-
if (!config?.attachSelector)
|
|
6070
|
-
return;
|
|
6071
|
-
const elements = document.querySelectorAll(config.attachSelector);
|
|
6072
|
-
logger.debug(`Found ${elements.length} elements matching "${config.attachSelector}"`);
|
|
6073
|
-
elements.forEach((el) => {
|
|
6074
|
-
// Skip if already attached
|
|
6075
|
-
if (el.dataset.sparkvaultAttached === 'true')
|
|
6076
|
-
return;
|
|
6077
|
-
el.dataset.sparkvaultAttached = 'true';
|
|
6078
|
-
logger.debug('Attaching click handler to element:', el);
|
|
6079
|
-
el.addEventListener('click', (e) => {
|
|
6080
|
-
logger.debug('Auth element clicked');
|
|
6081
|
-
e.preventDefault();
|
|
6082
|
-
e.stopPropagation();
|
|
6083
|
-
pop();
|
|
6084
|
-
});
|
|
6085
|
-
});
|
|
6086
|
-
}
|
|
6087
|
-
/**
|
|
6088
|
-
* Start observing for dynamically added elements
|
|
6089
|
-
*/
|
|
6090
|
-
function startObserver() {
|
|
6091
|
-
const config = state.config;
|
|
6092
|
-
if (!config?.attachSelector)
|
|
6093
|
-
return;
|
|
6094
|
-
if (typeof MutationObserver === 'undefined') {
|
|
6095
|
-
logger.warn('MutationObserver not available, dynamic elements will not be auto-attached');
|
|
6096
|
-
return;
|
|
6097
|
-
}
|
|
6098
|
-
state.observer = new MutationObserver((mutations) => {
|
|
6099
|
-
let shouldAttach = false;
|
|
6100
|
-
for (const mutation of mutations) {
|
|
6101
|
-
if (mutation.addedNodes.length > 0) {
|
|
6102
|
-
shouldAttach = true;
|
|
6103
|
-
break;
|
|
6104
|
-
}
|
|
6105
|
-
}
|
|
6106
|
-
if (shouldAttach) {
|
|
6107
|
-
attachHandlers();
|
|
6108
|
-
}
|
|
6109
|
-
});
|
|
6110
|
-
state.observer.observe(document.body, {
|
|
6111
|
-
childList: true,
|
|
6112
|
-
subtree: true,
|
|
6113
|
-
});
|
|
6114
|
-
logger.debug('MutationObserver started for dynamic elements');
|
|
6115
|
-
}
|
|
6116
8653
|
/**
|
|
6117
|
-
* Initialize the SDK from script tag attributes
|
|
8654
|
+
* Initialize the SDK from script tag attributes.
|
|
6118
8655
|
*
|
|
6119
8656
|
* Called automatically when the script loads.
|
|
6120
|
-
*
|
|
8657
|
+
* Returns the initialized instance if data-account-id is present, null otherwise.
|
|
6121
8658
|
*/
|
|
6122
8659
|
function autoInit(SparkVaultClass) {
|
|
6123
|
-
// Prevent double initialization
|
|
6124
|
-
if (state.initialized) {
|
|
6125
|
-
logger.debug('Auto-init already completed');
|
|
6126
|
-
return;
|
|
6127
|
-
}
|
|
6128
8660
|
// Only run in browser
|
|
6129
8661
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
6130
|
-
return;
|
|
8662
|
+
return null;
|
|
6131
8663
|
}
|
|
6132
|
-
|
|
8664
|
+
// Get the current script element
|
|
8665
|
+
const script = document.currentScript;
|
|
6133
8666
|
if (!script) {
|
|
6134
|
-
|
|
6135
|
-
return;
|
|
8667
|
+
return null;
|
|
6136
8668
|
}
|
|
6137
|
-
const
|
|
6138
|
-
|
|
6139
|
-
// Enable debug mode
|
|
6140
|
-
if (
|
|
8669
|
+
const accountId = script.dataset.accountId;
|
|
8670
|
+
const debug = script.dataset.debug;
|
|
8671
|
+
// Enable debug mode if requested
|
|
8672
|
+
if (debug === 'true') {
|
|
6141
8673
|
setDebugMode(true);
|
|
6142
8674
|
}
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
logger.debug('Account ID:', config.accountId || '(not set)');
|
|
6146
|
-
logger.debug('Attach Selector:', config.attachSelector || '(not set)');
|
|
6147
|
-
logger.debug('Success URL:', config.successUrl || '(not set)');
|
|
6148
|
-
logger.debug('Success Function:', config.successFunction || '(not set)');
|
|
6149
|
-
logger.debug('Error URL:', config.errorUrl || '(not set)');
|
|
6150
|
-
logger.debug('Error Function:', config.errorFunction || '(not set)');
|
|
6151
|
-
logger.debug('Debug:', config.debug);
|
|
6152
|
-
logger.groupEnd();
|
|
6153
|
-
// No account ID = no auto-init (manual init required)
|
|
6154
|
-
if (!config.accountId) {
|
|
8675
|
+
// No account ID = no auto-init
|
|
8676
|
+
if (!accountId) {
|
|
6155
8677
|
logger.debug('No data-account-id attribute, skipping auto-init');
|
|
6156
|
-
return;
|
|
8678
|
+
return null;
|
|
6157
8679
|
}
|
|
6158
|
-
|
|
8680
|
+
logger.info('Auto-initializing SDK...');
|
|
6159
8681
|
try {
|
|
6160
|
-
|
|
6161
|
-
accountId: config.accountId,
|
|
6162
|
-
preloadConfig: config.preloadConfig,
|
|
6163
|
-
timeout: config.timeout,
|
|
6164
|
-
});
|
|
6165
|
-
state.initialized = true;
|
|
8682
|
+
const instance = SparkVaultClass.init({ accountId });
|
|
6166
8683
|
logger.info('SDK initialized successfully');
|
|
8684
|
+
return instance;
|
|
6167
8685
|
}
|
|
6168
8686
|
catch (err) {
|
|
6169
8687
|
logger.error('Failed to initialize SDK:', err);
|
|
6170
|
-
return;
|
|
6171
|
-
}
|
|
6172
|
-
// Attach handlers if selector specified
|
|
6173
|
-
if (config.attachSelector) {
|
|
6174
|
-
// Wait for DOM to be ready
|
|
6175
|
-
if (document.readyState === 'loading') {
|
|
6176
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
6177
|
-
attachHandlers();
|
|
6178
|
-
startObserver();
|
|
6179
|
-
});
|
|
6180
|
-
}
|
|
6181
|
-
else {
|
|
6182
|
-
attachHandlers();
|
|
6183
|
-
startObserver();
|
|
6184
|
-
}
|
|
6185
|
-
}
|
|
6186
|
-
// Expose utility methods on SparkVault global for manual use
|
|
6187
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6188
|
-
const SparkVaultGlobal = window.SparkVault;
|
|
6189
|
-
if (SparkVaultGlobal) {
|
|
6190
|
-
// Add identity namespace for manual trigger
|
|
6191
|
-
SparkVaultGlobal.identity = {
|
|
6192
|
-
/** Open popup and use configured handlers (data-success-url, etc.) */
|
|
6193
|
-
pop,
|
|
6194
|
-
/** Render inline (requires target element, returns result) */
|
|
6195
|
-
render: (options) => {
|
|
6196
|
-
if (!state.instance) {
|
|
6197
|
-
return Promise.reject(new Error('SDK not initialized'));
|
|
6198
|
-
}
|
|
6199
|
-
return state.instance.identity.render(options);
|
|
6200
|
-
},
|
|
6201
|
-
/** @deprecated Use pop() instead */
|
|
6202
|
-
authenticate: pop,
|
|
6203
|
-
/** @deprecated Use pop() instead */
|
|
6204
|
-
verify: (options) => {
|
|
6205
|
-
if (!state.instance) {
|
|
6206
|
-
return Promise.reject(new Error('SDK not initialized'));
|
|
6207
|
-
}
|
|
6208
|
-
return state.instance.identity.pop(options);
|
|
6209
|
-
},
|
|
6210
|
-
};
|
|
6211
|
-
// For advanced users who need full SDK instance
|
|
6212
|
-
SparkVaultGlobal.getInstance = () => state.instance;
|
|
8688
|
+
return null;
|
|
6213
8689
|
}
|
|
6214
|
-
logger.info('Auto-init complete');
|
|
6215
8690
|
}
|
|
6216
8691
|
|
|
6217
8692
|
/**
|
|
6218
8693
|
* SparkVault JavaScript SDK
|
|
6219
8694
|
*
|
|
6220
|
-
* A unified SDK for Identity
|
|
8695
|
+
* A unified SDK for Identity and Vaults.
|
|
6221
8696
|
*
|
|
6222
|
-
* @example
|
|
8697
|
+
* @example CDN Usage (Recommended)
|
|
6223
8698
|
* ```html
|
|
6224
8699
|
* <script
|
|
6225
|
-
* async
|
|
6226
8700
|
* src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"
|
|
6227
8701
|
* data-account-id="acc_your_account"
|
|
6228
|
-
* data-attach-selector=".js-sparkvault-auth"
|
|
6229
|
-
* data-success-url="https://example.com/auth/verify-token"
|
|
6230
|
-
* data-debug="true"
|
|
6231
8702
|
* ></script>
|
|
6232
8703
|
*
|
|
6233
|
-
* <button class="js-sparkvault-auth">Login with SparkVault</button>
|
|
6234
|
-
* ```
|
|
6235
|
-
*
|
|
6236
|
-
* @example Manual Init
|
|
6237
|
-
* ```html
|
|
6238
|
-
* <script src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"></script>
|
|
6239
8704
|
* <script>
|
|
6240
|
-
*
|
|
6241
|
-
*
|
|
8705
|
+
* // SDK auto-initializes. Use JavaScript API:
|
|
8706
|
+
* SparkVault.identity.attach('.login-btn', {
|
|
8707
|
+
* onSuccess: (result) => console.log('Verified:', result.identity)
|
|
6242
8708
|
* });
|
|
6243
|
-
*
|
|
6244
|
-
* // Open identity verification modal
|
|
6245
|
-
* const result = await sv.identity.pop();
|
|
6246
|
-
* console.log(result.identity, result.identityType);
|
|
6247
8709
|
* </script>
|
|
6248
8710
|
* ```
|
|
8711
|
+
*
|
|
8712
|
+
* @example npm/Bundler Usage
|
|
8713
|
+
* ```typescript
|
|
8714
|
+
* import SparkVault from '@sparkvault/sdk';
|
|
8715
|
+
*
|
|
8716
|
+
* const sv = SparkVault.init({ accountId: 'acc_your_account' });
|
|
8717
|
+
*
|
|
8718
|
+
* const result = await sv.identity.verify();
|
|
8719
|
+
* console.log(result.identity, result.identityType);
|
|
8720
|
+
* ```
|
|
6249
8721
|
*/
|
|
6250
8722
|
class SparkVault {
|
|
6251
8723
|
constructor(config) {
|
|
@@ -6253,9 +8725,7 @@ class SparkVault {
|
|
|
6253
8725
|
this.config = resolveConfig(config);
|
|
6254
8726
|
const http = new HttpClient(this.config);
|
|
6255
8727
|
this.identity = new IdentityModule(this.config);
|
|
6256
|
-
this.sparks = new SparksModule(http);
|
|
6257
8728
|
this.vaults = new VaultsModule(this.config, http);
|
|
6258
|
-
this.rng = new RNGModule(http);
|
|
6259
8729
|
}
|
|
6260
8730
|
/**
|
|
6261
8731
|
* Initialize the SparkVault SDK.
|
|
@@ -6277,9 +8747,11 @@ class SparkVault {
|
|
|
6277
8747
|
}
|
|
6278
8748
|
}
|
|
6279
8749
|
if (typeof window !== 'undefined') {
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
8750
|
+
// Auto-initialize from script tag data-account-id attribute
|
|
8751
|
+
const instance = autoInit(SparkVault);
|
|
8752
|
+
// Expose instance (if auto-init succeeded) or class (for manual init)
|
|
8753
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8754
|
+
window.SparkVault = instance ?? SparkVault;
|
|
6283
8755
|
}
|
|
6284
8756
|
|
|
6285
8757
|
export { AuthenticationError, AuthorizationError, NetworkError, PopupBlockedError, SparkVault, SparkVaultError, TimeoutError, UserCancelledError, ValidationError, SparkVault as default, logger, setDebugMode };
|