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