@truly-you/trulyyou-web-sdk 0.1.23 → 0.1.25
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/index.d.ts +86 -0
- package/index.js +687 -0
- package/package.json +11 -34
- package/README.md +0 -57
- package/dist/index.d.ts +0 -2
- package/dist/index.esm.js +0 -9
- package/dist/index.esm.js.map +0 -1
- package/dist/index.js +0 -9
- package/dist/index.js.map +0 -1
- package/dist/index.umd.js +0 -9
- package/dist/index.umd.js.map +0 -1
- package/dist/sdk/TrulyYouSDK.d.ts +0 -96
- package/dist/types.d.ts +0 -36
- package/rollup.config.js +0 -58
- package/src/index.ts +0 -3
- package/src/sdk/TrulyYouSDK.ts +0 -2023
- package/src/types.ts +0 -40
- package/tsconfig.json +0 -20
package/src/sdk/TrulyYouSDK.ts
DELETED
|
@@ -1,2023 +0,0 @@
|
|
|
1
|
-
import { TrulyYouSDKConfig, FetchOptions, SigningResult, FetchResult } from '../types'
|
|
2
|
-
import Pusher from 'pusher-js'
|
|
3
|
-
|
|
4
|
-
export class TrulyYouSDK {
|
|
5
|
-
private config: TrulyYouSDKConfig
|
|
6
|
-
private frontendUrl: string
|
|
7
|
-
private apiUrl: string
|
|
8
|
-
private authAppId: string | undefined
|
|
9
|
-
private invisible: boolean
|
|
10
|
-
private targetElement: string | HTMLElement | undefined
|
|
11
|
-
private brandingCache: { primary?: string; background?: string; secondary?: string; textColor?: string; icon?: string; name?: string; authFlowId?: string; pusher?: { realtimeUrl?: string; appKey?: string } } | null = null
|
|
12
|
-
private realtimeUrl: string
|
|
13
|
-
private mockMobileDevice: boolean
|
|
14
|
-
|
|
15
|
-
constructor(config: TrulyYouSDKConfig) {
|
|
16
|
-
this.config = config
|
|
17
|
-
this.frontendUrl = config.frontendUrl || this.getDefaultFrontendUrl()
|
|
18
|
-
this.apiUrl = config.apiUrl || this.getDefaultApiUrl()
|
|
19
|
-
this.authAppId = config.authAppId
|
|
20
|
-
this.invisible = config.invisible || false
|
|
21
|
-
this.targetElement = config.targetElement
|
|
22
|
-
this.mockMobileDevice = config.mockMobileDevice || false
|
|
23
|
-
|
|
24
|
-
// Determine realtime WebSocket URL
|
|
25
|
-
if (config.realtimeUrl) {
|
|
26
|
-
this.realtimeUrl = config.realtimeUrl
|
|
27
|
-
console.log('[SDK]: Using provided realtimeUrl:', this.realtimeUrl)
|
|
28
|
-
} else {
|
|
29
|
-
// Infer from frontendUrl - replace http/https with ws/wss
|
|
30
|
-
const url = new URL(this.frontendUrl)
|
|
31
|
-
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
32
|
-
this.realtimeUrl = `${wsProtocol}//${url.host}`
|
|
33
|
-
console.log('[SDK]: No realtimeUrl provided, inferring from frontendUrl:', this.realtimeUrl)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Fetch branding on instantiation if authAppId is provided
|
|
37
|
-
if (this.authAppId) {
|
|
38
|
-
this.fetchBranding().catch(err => {
|
|
39
|
-
console.warn('[SDK]: Failed to fetch branding:', err)
|
|
40
|
-
})
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Helper to get keyId by authFlowId from localStorage
|
|
47
|
-
* Keys are stored as: trulyYouKeyId_<authFlowId> with value being just the keyId
|
|
48
|
-
*/
|
|
49
|
-
private getKeyIdByAuthFlowId(authFlowId: string): string | null {
|
|
50
|
-
if (typeof window === 'undefined') return null
|
|
51
|
-
|
|
52
|
-
const storageKey = `trulyYouKeyId_${authFlowId}`
|
|
53
|
-
const keyId = localStorage.getItem(storageKey)
|
|
54
|
-
|
|
55
|
-
if (keyId) {
|
|
56
|
-
console.log(`[SDK]: Found keyId for authFlowId ${authFlowId}, key: ${storageKey}`)
|
|
57
|
-
return keyId
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
console.log(`[SDK]: No keyId found for authFlowId: ${authFlowId}`)
|
|
61
|
-
return null
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Fetch app branding from SDK backend
|
|
66
|
-
* Returns true if authFlowId was successfully loaded
|
|
67
|
-
*/
|
|
68
|
-
private async fetchBranding(): Promise<boolean> {
|
|
69
|
-
if (!this.authAppId) {
|
|
70
|
-
console.warn('[SDK]: Cannot fetch branding - authAppId not configured')
|
|
71
|
-
return false
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
// Call SDK backend API directly
|
|
76
|
-
const response = await fetch(`${this.apiUrl}/api/apps/${this.authAppId}`, {
|
|
77
|
-
method: 'GET',
|
|
78
|
-
headers: { 'Content-Type': 'application/json' }
|
|
79
|
-
})
|
|
80
|
-
if (response.ok) {
|
|
81
|
-
const data = await response.json()
|
|
82
|
-
// Handle both formats: { app: {...} } or direct app object
|
|
83
|
-
const app = data.app || data
|
|
84
|
-
if (app?.colors || app?.icon || app?.name || app?.authFlowId) {
|
|
85
|
-
this.brandingCache = {
|
|
86
|
-
primary: app.colors?.primary,
|
|
87
|
-
background: app.colors?.background,
|
|
88
|
-
secondary: app.colors?.secondary,
|
|
89
|
-
textColor: app.colors?.textColor,
|
|
90
|
-
icon: app.icon,
|
|
91
|
-
name: app.name,
|
|
92
|
-
authFlowId: app.authFlowId,
|
|
93
|
-
...(app.pusher && {
|
|
94
|
-
pusher: {
|
|
95
|
-
realtimeUrl: app.pusher.realtimeUrl,
|
|
96
|
-
appKey: app.pusher.appKey
|
|
97
|
-
}
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
console.log('[SDK]: Branding and authFlowId fetched and cached:', this.brandingCache)
|
|
101
|
-
return !!app.authFlowId
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
console.warn('[SDK]: Failed to fetch branding - response not ok or missing app data')
|
|
105
|
-
return false
|
|
106
|
-
} catch (error) {
|
|
107
|
-
console.warn('[SDK]: Error fetching branding:', error)
|
|
108
|
-
return false
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Ensure authFlowId is loaded before proceeding with signing operations
|
|
114
|
-
* Throws error if authFlowId cannot be loaded
|
|
115
|
-
*/
|
|
116
|
-
private async ensureAuthFlowIdLoaded(): Promise<void> {
|
|
117
|
-
if (this.brandingCache?.authFlowId) {
|
|
118
|
-
console.log('[SDK]: authFlowId already loaded:', this.brandingCache.authFlowId)
|
|
119
|
-
return
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
console.log('[SDK]: authFlowId not yet loaded, fetching branding...')
|
|
123
|
-
const success = await this.fetchBranding()
|
|
124
|
-
|
|
125
|
-
if (!success || !this.brandingCache?.authFlowId) {
|
|
126
|
-
throw new Error('authFlowId is required for signing but not available. Please check that your app configuration includes an authFlowId.')
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
console.log('[SDK]: authFlowId loaded successfully:', this.brandingCache.authFlowId)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Generate QR code with app icon overlaid in center
|
|
134
|
-
*/
|
|
135
|
-
private async generateQRCodeWithIcon(qrData: string, imgElement: HTMLImageElement): Promise<void> {
|
|
136
|
-
return new Promise((resolve, reject) => {
|
|
137
|
-
// Step 1: Generate base QR code
|
|
138
|
-
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(qrData)}`
|
|
139
|
-
const qrImg = new Image()
|
|
140
|
-
qrImg.crossOrigin = 'anonymous'
|
|
141
|
-
|
|
142
|
-
qrImg.onload = () => {
|
|
143
|
-
// Step 2: Create canvas and draw QR code
|
|
144
|
-
const canvas = document.createElement('canvas')
|
|
145
|
-
canvas.width = 300
|
|
146
|
-
canvas.height = 300
|
|
147
|
-
const ctx = canvas.getContext('2d')
|
|
148
|
-
|
|
149
|
-
if (!ctx) {
|
|
150
|
-
reject(new Error('Failed to get canvas context'))
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Draw QR code
|
|
155
|
-
ctx.drawImage(qrImg, 0, 0, 300, 300)
|
|
156
|
-
|
|
157
|
-
// Step 3: Overlay app icon if available
|
|
158
|
-
if (this.brandingCache?.icon) {
|
|
159
|
-
// Try to load icon via proxy first (avoids CORS issues)
|
|
160
|
-
// If proxy fails, try direct load, then fallback to QR without icon
|
|
161
|
-
const tryLoadIcon = async (iconUrl: string, useProxy: boolean) => {
|
|
162
|
-
try {
|
|
163
|
-
let imageUrl = iconUrl
|
|
164
|
-
|
|
165
|
-
if (useProxy) {
|
|
166
|
-
// Fetch via proxy to avoid CORS
|
|
167
|
-
const proxyUrl = `${this.apiUrl}/api/proxy-icon?url=${encodeURIComponent(iconUrl)}`
|
|
168
|
-
const response = await fetch(proxyUrl)
|
|
169
|
-
if (!response.ok) {
|
|
170
|
-
throw new Error('Proxy fetch failed')
|
|
171
|
-
}
|
|
172
|
-
const blob = await response.blob()
|
|
173
|
-
imageUrl = URL.createObjectURL(blob)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const iconImg = new Image()
|
|
177
|
-
iconImg.crossOrigin = useProxy ? null : 'anonymous'
|
|
178
|
-
|
|
179
|
-
await new Promise<void>((resolveIcon, rejectIcon) => {
|
|
180
|
-
iconImg.onload = () => resolveIcon()
|
|
181
|
-
iconImg.onerror = () => rejectIcon(new Error('Image load failed'))
|
|
182
|
-
iconImg.src = imageUrl
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
// Calculate icon size (20% of QR code size)
|
|
186
|
-
const iconSize = 60
|
|
187
|
-
const iconX = (300 - iconSize) / 2
|
|
188
|
-
const iconY = (300 - iconSize) / 2
|
|
189
|
-
|
|
190
|
-
// Draw white background circle for icon
|
|
191
|
-
ctx.fillStyle = '#ffffff'
|
|
192
|
-
ctx.beginPath()
|
|
193
|
-
ctx.arc(150, 150, iconSize / 2 + 4, 0, 2 * Math.PI)
|
|
194
|
-
ctx.fill()
|
|
195
|
-
|
|
196
|
-
// Draw icon
|
|
197
|
-
ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize)
|
|
198
|
-
|
|
199
|
-
// Clean up object URL if we created one
|
|
200
|
-
if (useProxy && imageUrl.startsWith('blob:')) {
|
|
201
|
-
URL.revokeObjectURL(imageUrl)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Convert canvas to data URL and set as image source
|
|
205
|
-
imgElement.src = canvas.toDataURL('image/png')
|
|
206
|
-
resolve()
|
|
207
|
-
} catch (error) {
|
|
208
|
-
throw error
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Try proxy first, then direct, then fallback
|
|
213
|
-
const iconUrl = this.brandingCache.icon
|
|
214
|
-
tryLoadIcon(iconUrl, true).catch(() => {
|
|
215
|
-
// If proxy fails, try direct load
|
|
216
|
-
tryLoadIcon(iconUrl, false).catch(() => {
|
|
217
|
-
// If both fail, just use QR code without icon
|
|
218
|
-
imgElement.src = canvas.toDataURL('image/png')
|
|
219
|
-
resolve()
|
|
220
|
-
})
|
|
221
|
-
})
|
|
222
|
-
} else {
|
|
223
|
-
// No icon, just use QR code
|
|
224
|
-
imgElement.src = canvas.toDataURL('image/png')
|
|
225
|
-
resolve()
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
qrImg.onerror = () => {
|
|
230
|
-
reject(new Error('Failed to load QR code'))
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
qrImg.src = qrCodeUrl
|
|
234
|
-
})
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
private getDefaultFrontendUrl(): string {
|
|
238
|
-
if (typeof window !== 'undefined') {
|
|
239
|
-
const hostname = window.location.hostname
|
|
240
|
-
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
241
|
-
return 'http://localhost:3002'
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return 'https://sdks.verification.local'
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private getDefaultApiUrl(): string {
|
|
248
|
-
if (typeof window !== 'undefined') {
|
|
249
|
-
const hostname = window.location.hostname
|
|
250
|
-
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
251
|
-
return 'http://localhost:3003'
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return 'https://api.verification.local'
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Detect if running on iOS device
|
|
259
|
-
*/
|
|
260
|
-
private isIOSDevice(): boolean {
|
|
261
|
-
if (typeof window === 'undefined') return false
|
|
262
|
-
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
263
|
-
(navigator.platform === 'MacIntel' && (navigator as any).maxTouchPoints > 1)
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Detect if running on Safari browser
|
|
268
|
-
*/
|
|
269
|
-
private isSafariBrowser(): boolean {
|
|
270
|
-
if (typeof window === 'undefined') return false
|
|
271
|
-
return /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Detect if running on desktop device (not just viewport-based)
|
|
276
|
-
* Returns false if mockMobileDevice is enabled (treats as mobile)
|
|
277
|
-
*/
|
|
278
|
-
private isDesktopDevice(): boolean {
|
|
279
|
-
// If mock mobile device is enabled, always return false (treat as mobile)
|
|
280
|
-
if (this.mockMobileDevice === true) {
|
|
281
|
-
console.log('[SDK]: Mock mobile device mode enabled - treating device as mobile (no handoff)')
|
|
282
|
-
return false
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (typeof window === 'undefined') return false
|
|
286
|
-
|
|
287
|
-
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera
|
|
288
|
-
const isMobileUA = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i.test(userAgent.toLowerCase())
|
|
289
|
-
|
|
290
|
-
// Additional checks for actual mobile devices
|
|
291
|
-
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
|
292
|
-
const isMobileOrientation = window.screen && 'orientation' in window.screen
|
|
293
|
-
|
|
294
|
-
// Desktop if: not mobile UA pattern OR (mobile UA but no real touch/orientation support)
|
|
295
|
-
const isDesktop = !isMobileUA || (isMobileUA && !hasTouchScreen && !isMobileOrientation)
|
|
296
|
-
|
|
297
|
-
return isDesktop
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Resolve target element from string selector or HTMLElement
|
|
302
|
-
*/
|
|
303
|
-
private resolveTargetElement(): HTMLElement | null {
|
|
304
|
-
if (!this.targetElement) return null
|
|
305
|
-
|
|
306
|
-
if (typeof this.targetElement === 'string') {
|
|
307
|
-
return document.querySelector(this.targetElement)
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return this.targetElement
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Probe iframe to check if key exists in iframe's localStorage
|
|
315
|
-
* Also checks backend to verify key exists and is active
|
|
316
|
-
* REQUIRES authFlowId to be loaded first
|
|
317
|
-
* @param handoff - If true, probes for handoff keyId (with _handoff suffix)
|
|
318
|
-
*/
|
|
319
|
-
private async probeIframeForKey(handoff: boolean = false): Promise<string | null> {
|
|
320
|
-
// Ensure authFlowId is loaded before probing
|
|
321
|
-
await this.ensureAuthFlowIdLoaded()
|
|
322
|
-
|
|
323
|
-
if (!this.brandingCache?.authFlowId) {
|
|
324
|
-
console.error('[SDK-PROBE]: authFlowId is required but not available after fetch')
|
|
325
|
-
return null
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
return new Promise((resolve) => {
|
|
329
|
-
try {
|
|
330
|
-
const origin = window.location.origin
|
|
331
|
-
const iframe = document.createElement('iframe')
|
|
332
|
-
iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
|
|
333
|
-
|
|
334
|
-
// Create a probe endpoint URL - use dedicated probe.html for instant response (no React hydration)
|
|
335
|
-
const probeUrl = new URL(`${this.frontendUrl}/probe.html`)
|
|
336
|
-
probeUrl.searchParams.set('probe', 'true')
|
|
337
|
-
probeUrl.searchParams.set('origin', origin)
|
|
338
|
-
|
|
339
|
-
// Add authFlowId (guaranteed to exist now after ensureAuthFlowIdLoaded)
|
|
340
|
-
const authFlowId = this.brandingCache!.authFlowId!
|
|
341
|
-
probeUrl.searchParams.set('authFlowId', authFlowId)
|
|
342
|
-
if (handoff) {
|
|
343
|
-
probeUrl.searchParams.set('handoff', 'true')
|
|
344
|
-
}
|
|
345
|
-
console.log(`[SDK-PROBE]: Adding authFlowId to probe: ${authFlowId}${handoff ? ' (handoff)' : ''}`)
|
|
346
|
-
|
|
347
|
-
iframe.src = probeUrl.toString()
|
|
348
|
-
|
|
349
|
-
let timeout: NodeJS.Timeout | undefined
|
|
350
|
-
let backendCheckPromise: Promise<boolean> | null = null
|
|
351
|
-
|
|
352
|
-
const cleanup = () => {
|
|
353
|
-
window.removeEventListener('message', handleMessage)
|
|
354
|
-
if (iframe.parentNode) {
|
|
355
|
-
iframe.parentNode.removeChild(iframe)
|
|
356
|
-
}
|
|
357
|
-
if (timeout) {
|
|
358
|
-
clearTimeout(timeout)
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const handleMessage = async (event: MessageEvent) => {
|
|
363
|
-
const data = event.data as any
|
|
364
|
-
|
|
365
|
-
// Only process probe-related messages
|
|
366
|
-
if (data?.type === 'KEY_CHECK_RESULT' || data?.type === 'KEY_CHECK_FAILED') {
|
|
367
|
-
try {
|
|
368
|
-
const frontendOrigin = new URL(this.frontendUrl).origin
|
|
369
|
-
if (event.origin !== frontendOrigin) {
|
|
370
|
-
return
|
|
371
|
-
}
|
|
372
|
-
} catch (e) {
|
|
373
|
-
if (!event.origin.includes(window.location.hostname)) {
|
|
374
|
-
return
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (data?.type === 'KEY_CHECK_RESULT') {
|
|
379
|
-
cleanup()
|
|
380
|
-
if (data.hasKey && data.keyId) {
|
|
381
|
-
resolve(data.keyId)
|
|
382
|
-
} else {
|
|
383
|
-
resolve(null)
|
|
384
|
-
}
|
|
385
|
-
} else if (data?.type === 'KEY_CHECK_FAILED') {
|
|
386
|
-
cleanup()
|
|
387
|
-
resolve(null)
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
window.addEventListener('message', handleMessage)
|
|
393
|
-
document.body.appendChild(iframe)
|
|
394
|
-
|
|
395
|
-
// Timeout to allow for React hydration on sign page
|
|
396
|
-
timeout = setTimeout(() => {
|
|
397
|
-
console.log('[SDK-PROBE]: Timeout reached (3000ms), no response from iframe')
|
|
398
|
-
cleanup()
|
|
399
|
-
resolve(null)
|
|
400
|
-
}, 3000)
|
|
401
|
-
} catch (error) {
|
|
402
|
-
console.error('[SDK-PROBE]: Error in probeIframeForKey:', error)
|
|
403
|
-
resolve(null)
|
|
404
|
-
}
|
|
405
|
-
})
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Open enrollment popup and wait for completion
|
|
410
|
-
*/
|
|
411
|
-
private async enrollWithPopup(): Promise<string> {
|
|
412
|
-
if (!this.authAppId) {
|
|
413
|
-
throw new Error('authAppId is required for enrollment. Please configure authAppId in SDK config.')
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// authAppId is guaranteed to exist here due to check above
|
|
417
|
-
const appId = this.authAppId!
|
|
418
|
-
|
|
419
|
-
// Get app to retrieve authFlowId
|
|
420
|
-
const appResponse = await fetch(`${this.apiUrl}/api/apps/${appId}`)
|
|
421
|
-
|
|
422
|
-
if (!appResponse.ok) {
|
|
423
|
-
throw new Error('Failed to load app configuration')
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const appData = await appResponse.json()
|
|
427
|
-
const app = appData.app
|
|
428
|
-
|
|
429
|
-
if (!app.authFlowId) {
|
|
430
|
-
throw new Error('App does not have an authentication flow configured')
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Generate a clientId for this session
|
|
434
|
-
const clientId = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
|
435
|
-
|
|
436
|
-
// Create session directly via SDK backend
|
|
437
|
-
// Backend will fetch authFlowId from the app document
|
|
438
|
-
const sessionResponse = await fetch(`${this.apiUrl}/api/sessions`, {
|
|
439
|
-
method: 'POST',
|
|
440
|
-
headers: { 'Content-Type': 'application/json' },
|
|
441
|
-
body: JSON.stringify({
|
|
442
|
-
appId: appId,
|
|
443
|
-
clientId: clientId
|
|
444
|
-
})
|
|
445
|
-
})
|
|
446
|
-
|
|
447
|
-
if (!sessionResponse.ok) {
|
|
448
|
-
const errorData = await sessionResponse.json()
|
|
449
|
-
throw new Error(errorData.error || 'Failed to create enrollment session')
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const sessionData = await sessionResponse.json()
|
|
453
|
-
const sessionId = sessionData.data.sessionId
|
|
454
|
-
|
|
455
|
-
return new Promise((resolve, reject) => {
|
|
456
|
-
const origin = window.location.origin
|
|
457
|
-
|
|
458
|
-
// Build enrollment URL with branding colors from cache if available
|
|
459
|
-
const enrollUrlParams = new URLSearchParams({
|
|
460
|
-
authAppId: appId,
|
|
461
|
-
sessionId: sessionId,
|
|
462
|
-
returnTo: 'popup',
|
|
463
|
-
origin: origin
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
// Add branding colors to URL params if cached
|
|
467
|
-
if (this.brandingCache?.primary) {
|
|
468
|
-
enrollUrlParams.set('primaryColor', this.brandingCache.primary)
|
|
469
|
-
}
|
|
470
|
-
if (this.brandingCache?.background) {
|
|
471
|
-
enrollUrlParams.set('backgroundColor', this.brandingCache.background)
|
|
472
|
-
}
|
|
473
|
-
if (this.brandingCache?.secondary) {
|
|
474
|
-
enrollUrlParams.set('secondaryColor', this.brandingCache.secondary)
|
|
475
|
-
}
|
|
476
|
-
if (this.brandingCache?.textColor) {
|
|
477
|
-
enrollUrlParams.set('textColor', this.brandingCache.textColor)
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const enrollUrl = `${this.frontendUrl}/enroll?${enrollUrlParams.toString()}`
|
|
481
|
-
|
|
482
|
-
console.log('[SDK]: Opening enrollment popup:', enrollUrl)
|
|
483
|
-
|
|
484
|
-
const popup = window.open(
|
|
485
|
-
enrollUrl,
|
|
486
|
-
'trulyyou-enroll',
|
|
487
|
-
'width=600,height=700,scrollbars=yes,resizable=yes'
|
|
488
|
-
)
|
|
489
|
-
|
|
490
|
-
if (!popup) {
|
|
491
|
-
reject(new Error('Failed to open enrollment popup. Please allow popups for this site.'))
|
|
492
|
-
return
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
let timeout: NodeJS.Timeout
|
|
496
|
-
let checkPopupClosed: NodeJS.Timeout
|
|
497
|
-
let successReceived = false
|
|
498
|
-
|
|
499
|
-
// Set up postMessage listener
|
|
500
|
-
const handleMessage = (event: MessageEvent) => {
|
|
501
|
-
console.log('[SDK]: Enrollment popup message received:', event.origin, event.data)
|
|
502
|
-
|
|
503
|
-
// Verify origin matches frontend URL
|
|
504
|
-
try {
|
|
505
|
-
const frontendOrigin = new URL(this.frontendUrl).origin
|
|
506
|
-
if (event.origin !== frontendOrigin) {
|
|
507
|
-
console.log('[SDK]: Origin mismatch, ignoring message')
|
|
508
|
-
return
|
|
509
|
-
}
|
|
510
|
-
} catch (e) {
|
|
511
|
-
// If frontendUrl is relative or invalid, check if it matches current origin
|
|
512
|
-
if (!event.origin.includes(window.location.hostname)) {
|
|
513
|
-
console.log('[SDK]: Origin mismatch, ignoring message')
|
|
514
|
-
return
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
if (event.data && event.data.type === 'ENROLLMENT_SUCCESS') {
|
|
519
|
-
successReceived = true
|
|
520
|
-
|
|
521
|
-
// Clear timeouts
|
|
522
|
-
if (timeout) clearTimeout(timeout)
|
|
523
|
-
if (checkPopupClosed) clearInterval(checkPopupClosed)
|
|
524
|
-
|
|
525
|
-
// Remove listener
|
|
526
|
-
window.removeEventListener('message', handleMessage)
|
|
527
|
-
|
|
528
|
-
// Get keyId from message (enrollment page already stored it with authFlowId)
|
|
529
|
-
const keyIdFromMessage = event.data.keyId
|
|
530
|
-
|
|
531
|
-
if (keyIdFromMessage) {
|
|
532
|
-
console.log('[SDK]: Enrollment successful, keyId obtained from message:', keyIdFromMessage)
|
|
533
|
-
// Note: keyId is already stored by enrollment page as trulyYouKeyId_<authFlowId>
|
|
534
|
-
resolve(keyIdFromMessage)
|
|
535
|
-
} else {
|
|
536
|
-
// Fallback: check localStorage using authFlowId
|
|
537
|
-
setTimeout(() => {
|
|
538
|
-
let delayedKeyId: string | null = null
|
|
539
|
-
if (this.brandingCache?.authFlowId) {
|
|
540
|
-
delayedKeyId = this.getKeyIdByAuthFlowId(this.brandingCache.authFlowId)
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (delayedKeyId) {
|
|
544
|
-
console.log('[SDK]: Enrollment successful, keyId obtained (delayed) using authFlowId:', delayedKeyId)
|
|
545
|
-
resolve(delayedKeyId)
|
|
546
|
-
} else {
|
|
547
|
-
reject(new Error('Enrollment completed but no keyId found in message or localStorage'))
|
|
548
|
-
}
|
|
549
|
-
}, 500)
|
|
550
|
-
}
|
|
551
|
-
} else if (event.data && event.data.type === 'ENROLLMENT_ERROR') {
|
|
552
|
-
successReceived = true
|
|
553
|
-
if (timeout) clearTimeout(timeout)
|
|
554
|
-
if (checkPopupClosed) clearInterval(checkPopupClosed)
|
|
555
|
-
window.removeEventListener('message', handleMessage)
|
|
556
|
-
reject(new Error(event.data.error || 'Enrollment failed'))
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
window.addEventListener('message', handleMessage)
|
|
561
|
-
|
|
562
|
-
// Timeout after 10 minutes
|
|
563
|
-
timeout = setTimeout(() => {
|
|
564
|
-
if (!successReceived) {
|
|
565
|
-
window.removeEventListener('message', handleMessage)
|
|
566
|
-
if (checkPopupClosed) clearInterval(checkPopupClosed)
|
|
567
|
-
reject(new Error('Enrollment timeout'))
|
|
568
|
-
}
|
|
569
|
-
}, 10 * 60 * 1000)
|
|
570
|
-
|
|
571
|
-
// Check if popup was closed manually
|
|
572
|
-
checkPopupClosed = setInterval(() => {
|
|
573
|
-
if (popup.closed && !successReceived) {
|
|
574
|
-
clearInterval(checkPopupClosed)
|
|
575
|
-
clearTimeout(timeout)
|
|
576
|
-
window.removeEventListener('message', handleMessage)
|
|
577
|
-
reject(new Error('Enrollment popup was closed'))
|
|
578
|
-
}
|
|
579
|
-
}, 500)
|
|
580
|
-
})
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Sign with iframe (mobile device)
|
|
585
|
-
*/
|
|
586
|
-
private async signWithIframe(apiCallStructure: {
|
|
587
|
-
body: any
|
|
588
|
-
uri: string
|
|
589
|
-
method: string
|
|
590
|
-
headers?: any
|
|
591
|
-
}): Promise<SigningResult> {
|
|
592
|
-
return new Promise((resolve, reject) => {
|
|
593
|
-
try {
|
|
594
|
-
const signId = `sign_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
|
595
|
-
const targetElement = this.resolveTargetElement()
|
|
596
|
-
|
|
597
|
-
// Create signing loading overlay if we have a target element
|
|
598
|
-
const createSigningLoadingOverlay = () => {
|
|
599
|
-
if (!targetElement) return null
|
|
600
|
-
|
|
601
|
-
const overlay = document.createElement('div')
|
|
602
|
-
overlay.id = `signing-overlay-${signId}`
|
|
603
|
-
overlay.style.cssText = `
|
|
604
|
-
position: absolute;
|
|
605
|
-
top: 0;
|
|
606
|
-
left: 0;
|
|
607
|
-
right: 0;
|
|
608
|
-
bottom: 0;
|
|
609
|
-
background-color: rgba(255, 255, 255, 0.9);
|
|
610
|
-
display: flex;
|
|
611
|
-
flex-direction: column;
|
|
612
|
-
align-items: center;
|
|
613
|
-
justify-content: center;
|
|
614
|
-
z-index: 10000;
|
|
615
|
-
border-radius: 8px;
|
|
616
|
-
`
|
|
617
|
-
|
|
618
|
-
const spinner = document.createElement('div')
|
|
619
|
-
spinner.style.cssText = `
|
|
620
|
-
width: 48px;
|
|
621
|
-
height: 48px;
|
|
622
|
-
border: 4px solid ${this.config.spinnerBgColor || '#e5e7eb'};
|
|
623
|
-
border-top-color: ${this.config.spinnerColor || '#2563eb'};
|
|
624
|
-
border-radius: 50%;
|
|
625
|
-
animation: spin 1s linear infinite;
|
|
626
|
-
margin-bottom: 16px;
|
|
627
|
-
`
|
|
628
|
-
|
|
629
|
-
const text = document.createElement('div')
|
|
630
|
-
text.textContent = 'Authenticating...'
|
|
631
|
-
text.style.cssText = `
|
|
632
|
-
color: ${this.config.spinnerTextColor || '#6b7280'};
|
|
633
|
-
font-size: 14px;
|
|
634
|
-
font-weight: 500;
|
|
635
|
-
`
|
|
636
|
-
|
|
637
|
-
// Add CSS animation
|
|
638
|
-
const style = document.createElement('style')
|
|
639
|
-
style.textContent = `
|
|
640
|
-
@keyframes spin {
|
|
641
|
-
0% { transform: rotate(0deg); }
|
|
642
|
-
100% { transform: rotate(360deg); }
|
|
643
|
-
}
|
|
644
|
-
`
|
|
645
|
-
document.head.appendChild(style)
|
|
646
|
-
|
|
647
|
-
overlay.appendChild(spinner)
|
|
648
|
-
overlay.appendChild(text)
|
|
649
|
-
|
|
650
|
-
return overlay
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Create iframe for signing
|
|
654
|
-
const iframe = document.createElement('iframe')
|
|
655
|
-
iframe.name = `trulyyou-sign-frame-${signId}`
|
|
656
|
-
iframe.scrolling = 'no'
|
|
657
|
-
iframe.setAttribute('scrolling', 'no')
|
|
658
|
-
// Allow WebAuthn in iframe (required for cross-origin iframes)
|
|
659
|
-
iframe.setAttribute('allow', 'publickey-credentials-get *; publickey-credentials-create *')
|
|
660
|
-
|
|
661
|
-
// Encode payload
|
|
662
|
-
const sortObjectByKeys = (obj: any): any => {
|
|
663
|
-
if (obj === null || typeof obj !== 'object' || obj instanceof Array) {
|
|
664
|
-
return obj
|
|
665
|
-
}
|
|
666
|
-
const sorted: any = {}
|
|
667
|
-
Object.keys(obj).sort().forEach(key => {
|
|
668
|
-
sorted[key] = sortObjectByKeys(obj[key])
|
|
669
|
-
})
|
|
670
|
-
return sorted
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
const createPayloadBase64 = (data: any, uri: string, method: string, queryParams: any) => {
|
|
674
|
-
const dataToProcess = data === undefined || data === null ? {} : data
|
|
675
|
-
const queryParamsToProcess = queryParams === undefined || queryParams === null ? {} : queryParams
|
|
676
|
-
|
|
677
|
-
const sortedData = sortObjectByKeys(dataToProcess)
|
|
678
|
-
const sortedQueryParams = sortObjectByKeys(queryParamsToProcess)
|
|
679
|
-
|
|
680
|
-
const encodedBody = btoa(JSON.stringify(sortedData))
|
|
681
|
-
const encodedQueryParams = btoa(JSON.stringify(sortedQueryParams))
|
|
682
|
-
|
|
683
|
-
const payloadData = {
|
|
684
|
-
method: method,
|
|
685
|
-
uriId: uri,
|
|
686
|
-
requestBody: encodedBody,
|
|
687
|
-
queryParams: encodedQueryParams,
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
return {
|
|
691
|
-
fullbase64: btoa(JSON.stringify(payloadData)),
|
|
692
|
-
sortedData: sortedData,
|
|
693
|
-
sortedQueryParams: sortedQueryParams,
|
|
694
|
-
encodedBody: encodedBody,
|
|
695
|
-
encodedQueryParams: encodedQueryParams,
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const base64EncodedResponse = createPayloadBase64(
|
|
700
|
-
apiCallStructure.body || {},
|
|
701
|
-
apiCallStructure.uri,
|
|
702
|
-
apiCallStructure.method,
|
|
703
|
-
{}
|
|
704
|
-
)
|
|
705
|
-
const encodedPayload = base64EncodedResponse.fullbase64
|
|
706
|
-
|
|
707
|
-
// Prepare attestation data for iframe
|
|
708
|
-
const attestationDataForIframe = {
|
|
709
|
-
payload: encodedPayload,
|
|
710
|
-
apiCallStructure: apiCallStructure
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
const encodedData = btoa(JSON.stringify(attestationDataForIframe))
|
|
714
|
-
|
|
715
|
-
// Create form for POST submission to /sign/submit API route
|
|
716
|
-
const form = document.createElement('form')
|
|
717
|
-
form.method = 'POST'
|
|
718
|
-
form.action = `${this.frontendUrl}/sign/submit`
|
|
719
|
-
form.target = iframe.name
|
|
720
|
-
form.style.display = 'none'
|
|
721
|
-
|
|
722
|
-
// Add form data as hidden inputs
|
|
723
|
-
const attestationDataInput = document.createElement('input')
|
|
724
|
-
attestationDataInput.type = 'hidden'
|
|
725
|
-
attestationDataInput.name = 'attestationData'
|
|
726
|
-
attestationDataInput.value = encodedData
|
|
727
|
-
form.appendChild(attestationDataInput)
|
|
728
|
-
|
|
729
|
-
const signIdInput = document.createElement('input')
|
|
730
|
-
signIdInput.type = 'hidden'
|
|
731
|
-
signIdInput.name = 'signId'
|
|
732
|
-
signIdInput.value = signId
|
|
733
|
-
form.appendChild(signIdInput)
|
|
734
|
-
|
|
735
|
-
const modeInput = document.createElement('input')
|
|
736
|
-
modeInput.type = 'hidden'
|
|
737
|
-
modeInput.name = 'mode'
|
|
738
|
-
modeInput.value = 'embedded'
|
|
739
|
-
form.appendChild(modeInput)
|
|
740
|
-
|
|
741
|
-
// Determine if iframe will be visible (for mobile: not invisible, or iOS/Safari always visible)
|
|
742
|
-
const isIframeVisible = !this.invisible || this.isIOSDevice() || this.isSafariBrowser()
|
|
743
|
-
|
|
744
|
-
// Add branding colors and visibility flag to form data
|
|
745
|
-
const brandingInput = document.createElement('input')
|
|
746
|
-
brandingInput.type = 'hidden'
|
|
747
|
-
brandingInput.name = 'branding'
|
|
748
|
-
brandingInput.value = JSON.stringify({
|
|
749
|
-
primary: this.brandingCache?.primary || this.config.spinnerColor || '#2563eb',
|
|
750
|
-
background: this.brandingCache?.background || '#ffffff',
|
|
751
|
-
secondary: this.brandingCache?.secondary || '#2563eb',
|
|
752
|
-
textColor: this.brandingCache?.textColor || this.config.spinnerTextColor || '#6b7280',
|
|
753
|
-
spinnerColor: this.config.spinnerColor || this.brandingCache?.primary || '#2563eb',
|
|
754
|
-
spinnerBgColor: this.config.spinnerBgColor || '#e5e7eb',
|
|
755
|
-
spinnerTextColor: this.config.spinnerTextColor || this.brandingCache?.textColor || '#6b7280',
|
|
756
|
-
visible: isIframeVisible
|
|
757
|
-
})
|
|
758
|
-
form.appendChild(brandingInput)
|
|
759
|
-
|
|
760
|
-
// Add authFlowId so sign page can retrieve keyId from localStorage
|
|
761
|
-
const authFlowIdInput = document.createElement('input')
|
|
762
|
-
authFlowIdInput.type = 'hidden'
|
|
763
|
-
authFlowIdInput.name = 'authFlowId'
|
|
764
|
-
authFlowIdInput.value = this.brandingCache?.authFlowId || ''
|
|
765
|
-
form.appendChild(authFlowIdInput)
|
|
766
|
-
|
|
767
|
-
// Mobile device logic (mock mobile OR actual mobile - both treated the same)
|
|
768
|
-
const isMobile = this.mockMobileDevice || !this.isDesktopDevice()
|
|
769
|
-
|
|
770
|
-
if (isMobile) {
|
|
771
|
-
// Mobile: NEVER full screen
|
|
772
|
-
// iOS/Safari: ALWAYS use target element if provided (WebAuthn requires visible iframe)
|
|
773
|
-
if (this.isIOSDevice() || this.isSafariBrowser()) {
|
|
774
|
-
if (targetElement) {
|
|
775
|
-
// iOS/Safari: Use target element (required for WebAuthn)
|
|
776
|
-
const targetStyle = window.getComputedStyle(targetElement)
|
|
777
|
-
if (targetStyle.position === 'static') {
|
|
778
|
-
targetElement.style.position = 'relative'
|
|
779
|
-
}
|
|
780
|
-
const originalHeight = targetElement.style.height || targetStyle.height
|
|
781
|
-
if (!originalHeight || originalHeight === 'auto') {
|
|
782
|
-
const computedHeight = targetElement.offsetHeight
|
|
783
|
-
if (computedHeight > 0) {
|
|
784
|
-
targetElement.style.height = `${computedHeight}px`
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
|
|
788
|
-
targetElement.appendChild(iframe)
|
|
789
|
-
// No overlay needed - sign page is hidden when embedded
|
|
790
|
-
} else {
|
|
791
|
-
// iOS/Safari with no target element: must use invisible (can't use full screen on mobile)
|
|
792
|
-
console.warn('[SDK]: Mobile iOS/Safari with no target element - using invisible iframe (WebAuthn may fail)')
|
|
793
|
-
iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
|
|
794
|
-
}
|
|
795
|
-
} else {
|
|
796
|
-
// Mobile non-iOS/Safari: invisible if invisible=true, target element if invisible=false
|
|
797
|
-
if (this.invisible) {
|
|
798
|
-
iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
|
|
799
|
-
} else if (targetElement) {
|
|
800
|
-
// Visible iframe in target element
|
|
801
|
-
const targetStyle = window.getComputedStyle(targetElement)
|
|
802
|
-
if (targetStyle.position === 'static') {
|
|
803
|
-
targetElement.style.position = 'relative'
|
|
804
|
-
}
|
|
805
|
-
const originalHeight = targetElement.style.height || targetStyle.height
|
|
806
|
-
if (!originalHeight || originalHeight === 'auto') {
|
|
807
|
-
const computedHeight = targetElement.offsetHeight
|
|
808
|
-
if (computedHeight > 0) {
|
|
809
|
-
targetElement.style.height = `${computedHeight}px`
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
|
|
813
|
-
targetElement.appendChild(iframe)
|
|
814
|
-
// No overlay - sign page is hidden when embedded
|
|
815
|
-
} else {
|
|
816
|
-
// No target element - use invisible (can't use full screen on mobile)
|
|
817
|
-
console.warn('[SDK]: Mobile device with invisible=true but no target element - using invisible iframe')
|
|
818
|
-
iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
} else {
|
|
822
|
-
// Desktop device: can use full screen (only when opened directly on Device B after handoff)
|
|
823
|
-
// But when called from SDK, prefer target element or invisible
|
|
824
|
-
if (this.invisible) {
|
|
825
|
-
iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
|
|
826
|
-
} else if (targetElement) {
|
|
827
|
-
// Visible iframe in target element
|
|
828
|
-
const targetStyle = window.getComputedStyle(targetElement)
|
|
829
|
-
if (targetStyle.position === 'static') {
|
|
830
|
-
targetElement.style.position = 'relative'
|
|
831
|
-
}
|
|
832
|
-
const originalHeight = targetElement.style.height || targetStyle.height
|
|
833
|
-
if (!originalHeight || originalHeight === 'auto') {
|
|
834
|
-
const computedHeight = targetElement.offsetHeight
|
|
835
|
-
if (computedHeight > 0) {
|
|
836
|
-
targetElement.style.height = `${computedHeight}px`
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
|
|
840
|
-
targetElement.appendChild(iframe)
|
|
841
|
-
const overlay = createSigningLoadingOverlay()
|
|
842
|
-
if (overlay) {
|
|
843
|
-
targetElement.appendChild(overlay)
|
|
844
|
-
}
|
|
845
|
-
} else {
|
|
846
|
-
// Desktop: full screen overlay (only when no target element - Device B after handoff)
|
|
847
|
-
iframe.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999; background: white;`
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// Append form to body
|
|
852
|
-
document.body.appendChild(form)
|
|
853
|
-
|
|
854
|
-
// Append iframe - only to body if not already appended to target element above
|
|
855
|
-
// Check if iframe has a parent (was already appended to target element)
|
|
856
|
-
if (!iframe.parentNode) {
|
|
857
|
-
document.body.appendChild(iframe)
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
let timeout: NodeJS.Timeout | undefined
|
|
861
|
-
let overlayElement: HTMLElement | null = null
|
|
862
|
-
|
|
863
|
-
const cleanup = () => {
|
|
864
|
-
window.removeEventListener('message', handleMessage)
|
|
865
|
-
if (form.parentNode) {
|
|
866
|
-
form.parentNode.removeChild(form)
|
|
867
|
-
}
|
|
868
|
-
if (iframe.parentNode) {
|
|
869
|
-
iframe.parentNode.removeChild(iframe)
|
|
870
|
-
}
|
|
871
|
-
if (overlayElement && overlayElement.parentNode) {
|
|
872
|
-
overlayElement.parentNode.removeChild(overlayElement)
|
|
873
|
-
}
|
|
874
|
-
if (timeout) {
|
|
875
|
-
clearTimeout(timeout)
|
|
876
|
-
}
|
|
877
|
-
// Remove height constraint from target element
|
|
878
|
-
if (targetElement && !this.invisible) {
|
|
879
|
-
targetElement.style.height = ''
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
const handleMessage = (event: MessageEvent) => {
|
|
884
|
-
try {
|
|
885
|
-
const frontendOrigin = new URL(this.frontendUrl).origin
|
|
886
|
-
if (event.origin !== frontendOrigin) return
|
|
887
|
-
} catch (e) {
|
|
888
|
-
if (!event.origin.includes(window.location.hostname)) return
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
const data = event.data as any
|
|
892
|
-
// Handle both SIGNING_COMPLETE and SIGNATURE_COMPLETE (sign page sends SIGNATURE_COMPLETE)
|
|
893
|
-
if ((data?.type === 'SIGNING_COMPLETE' || data?.type === 'SIGNATURE_COMPLETE') && data?.signId === signId) {
|
|
894
|
-
console.log('[SDK]: Signature complete message received:', { type: data.type, hasSignature: !!data.signature || !!data.signatureBase64 })
|
|
895
|
-
cleanup()
|
|
896
|
-
if (data.error) {
|
|
897
|
-
reject(new Error(data.error))
|
|
898
|
-
} else {
|
|
899
|
-
// Sign page sends signatureBase64, but we expect signature - use either field
|
|
900
|
-
const signature = data.signature || data.signatureBase64
|
|
901
|
-
if (!signature) {
|
|
902
|
-
reject(new Error('No signature in response'))
|
|
903
|
-
return
|
|
904
|
-
}
|
|
905
|
-
resolve({
|
|
906
|
-
signature: signature,
|
|
907
|
-
keyId: data.keyId || ''
|
|
908
|
-
})
|
|
909
|
-
}
|
|
910
|
-
} else if (data?.type === 'SIGNING_ERROR' && data?.signId === signId) {
|
|
911
|
-
cleanup()
|
|
912
|
-
reject(new Error(data.error || 'Signing failed'))
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
window.addEventListener('message', handleMessage)
|
|
917
|
-
|
|
918
|
-
// Submit form to iframe
|
|
919
|
-
form.submit()
|
|
920
|
-
|
|
921
|
-
// Remove form after submission
|
|
922
|
-
setTimeout(() => {
|
|
923
|
-
if (form.parentNode) {
|
|
924
|
-
form.parentNode.removeChild(form)
|
|
925
|
-
}
|
|
926
|
-
}, 100)
|
|
927
|
-
|
|
928
|
-
// Store overlay reference for cleanup
|
|
929
|
-
if (targetElement && !this.invisible) {
|
|
930
|
-
overlayElement = targetElement.querySelector(`#signing-overlay-${signId}`) as HTMLElement
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// Timeout after 5 minutes
|
|
934
|
-
timeout = setTimeout(() => {
|
|
935
|
-
cleanup()
|
|
936
|
-
reject(new Error('Signing timeout'))
|
|
937
|
-
}, 5 * 60 * 1000)
|
|
938
|
-
|
|
939
|
-
} catch (error: any) {
|
|
940
|
-
reject(error)
|
|
941
|
-
}
|
|
942
|
-
})
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
/**
|
|
946
|
-
* Sign with WebSocket handoff (desktop device)
|
|
947
|
-
*/
|
|
948
|
-
private async signWithWebSocketHandoff(apiCallStructure: {
|
|
949
|
-
body: any
|
|
950
|
-
uri: string
|
|
951
|
-
method: string
|
|
952
|
-
headers?: any
|
|
953
|
-
}, signatureId: string): Promise<SigningResult> {
|
|
954
|
-
return new Promise((resolve, reject) => {
|
|
955
|
-
try {
|
|
956
|
-
console.log('[SDK]: Desktop device detected, using WebSocket handoff with signatureId:', signatureId)
|
|
957
|
-
|
|
958
|
-
// Get primary color and app name from branding cache (already fetched on SDK instantiation)
|
|
959
|
-
const primaryColor = this.brandingCache?.primary || '#2563eb'
|
|
960
|
-
const authAppName = this.brandingCache?.name || 'TrulyYou'
|
|
961
|
-
|
|
962
|
-
// Encode signatureId, primaryColor, and authAppName as base64 JSON for URL parameter
|
|
963
|
-
const encodedData = {
|
|
964
|
-
signatureId: signatureId,
|
|
965
|
-
primaryColor: primaryColor,
|
|
966
|
-
authAppName: authAppName
|
|
967
|
-
}
|
|
968
|
-
const encodedThing = btoa(JSON.stringify(encodedData))
|
|
969
|
-
|
|
970
|
-
// Build sign URL with encoded parameter (no query params)
|
|
971
|
-
const signUrl = `${this.frontendUrl}/sign/${encodedThing}`
|
|
972
|
-
|
|
973
|
-
// Encode payload
|
|
974
|
-
const sortObjectByKeys = (obj: any): any => {
|
|
975
|
-
if (obj === null || typeof obj !== 'object' || obj instanceof Array) {
|
|
976
|
-
return obj
|
|
977
|
-
}
|
|
978
|
-
const sorted: any = {}
|
|
979
|
-
Object.keys(obj).sort().forEach(key => {
|
|
980
|
-
sorted[key] = sortObjectByKeys(obj[key])
|
|
981
|
-
})
|
|
982
|
-
return sorted
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
const createPayloadBase64 = (data: any, uri: string, method: string, queryParams: any) => {
|
|
986
|
-
const dataToProcess = data === undefined || data === null ? {} : data
|
|
987
|
-
const queryParamsToProcess = queryParams === undefined || queryParams === null ? {} : queryParams
|
|
988
|
-
|
|
989
|
-
const sortedData = sortObjectByKeys(dataToProcess)
|
|
990
|
-
const sortedQueryParams = sortObjectByKeys(queryParamsToProcess)
|
|
991
|
-
|
|
992
|
-
const encodedBody = btoa(JSON.stringify(sortedData))
|
|
993
|
-
const encodedQueryParams = btoa(JSON.stringify(sortedQueryParams))
|
|
994
|
-
|
|
995
|
-
const payloadData = {
|
|
996
|
-
method: method,
|
|
997
|
-
uriId: uri,
|
|
998
|
-
requestBody: encodedBody,
|
|
999
|
-
queryParams: encodedQueryParams,
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
return {
|
|
1003
|
-
fullbase64: btoa(JSON.stringify(payloadData)),
|
|
1004
|
-
sortedData: sortedData,
|
|
1005
|
-
sortedQueryParams: sortedQueryParams,
|
|
1006
|
-
encodedBody: encodedBody,
|
|
1007
|
-
encodedQueryParams: encodedQueryParams,
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
const base64EncodedResponse = createPayloadBase64(
|
|
1012
|
-
apiCallStructure.body || {},
|
|
1013
|
-
apiCallStructure.uri,
|
|
1014
|
-
apiCallStructure.method,
|
|
1015
|
-
{}
|
|
1016
|
-
)
|
|
1017
|
-
const encodedPayload = base64EncodedResponse.fullbase64
|
|
1018
|
-
|
|
1019
|
-
// Prepare attestation data for sending
|
|
1020
|
-
const attestationData = {
|
|
1021
|
-
payload: encodedPayload,
|
|
1022
|
-
apiCallStructure: apiCallStructure
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// Get Pusher config from branding cache (from API) or fall back to config/constructor values
|
|
1026
|
-
const pusherConfigFromBranding = this.brandingCache?.pusher
|
|
1027
|
-
const realtimeUrlToUse = pusherConfigFromBranding?.realtimeUrl || this.realtimeUrl
|
|
1028
|
-
const pusherAppKeyToUse = pusherConfigFromBranding?.appKey || this.config.pusherAppKey || 'app-key'
|
|
1029
|
-
|
|
1030
|
-
// Parse realtimeUrl to extract host/port for Pusher
|
|
1031
|
-
let pusherHost: string | undefined
|
|
1032
|
-
let pusherPort: number | undefined
|
|
1033
|
-
let forceTLS = false
|
|
1034
|
-
let encrypted = true
|
|
1035
|
-
|
|
1036
|
-
try {
|
|
1037
|
-
const realtimeUrlObj = new URL(realtimeUrlToUse.startsWith('ws://') || realtimeUrlToUse.startsWith('wss://')
|
|
1038
|
-
? realtimeUrlToUse
|
|
1039
|
-
: realtimeUrlToUse.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'))
|
|
1040
|
-
|
|
1041
|
-
pusherHost = realtimeUrlObj.hostname
|
|
1042
|
-
pusherPort = realtimeUrlObj.port ? parseInt(realtimeUrlObj.port, 10) : (realtimeUrlObj.protocol === 'wss:' ? 443 : 80)
|
|
1043
|
-
forceTLS = realtimeUrlObj.protocol === 'wss:'
|
|
1044
|
-
encrypted = realtimeUrlObj.protocol === 'wss:'
|
|
1045
|
-
} catch (e) {
|
|
1046
|
-
console.warn('[SDK]: Error parsing realtimeUrl, using defaults:', e)
|
|
1047
|
-
pusherHost = '127.0.0.1'
|
|
1048
|
-
pusherPort = 6001
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
// Get Pusher app key from branding cache, config, or default
|
|
1052
|
-
const pusherAppKey = pusherAppKeyToUse
|
|
1053
|
-
|
|
1054
|
-
// Merge custom Pusher config with parsed values
|
|
1055
|
-
// When using custom wsHost/wsPort, we need to set cluster to empty string to disable default cluster behavior
|
|
1056
|
-
const pusherOptions: any = {
|
|
1057
|
-
wsHost: this.config.pusherConfig?.wsHost || pusherHost,
|
|
1058
|
-
wsPort: this.config.pusherConfig?.wsPort || pusherPort,
|
|
1059
|
-
cluster: this.config.pusherConfig?.cluster || '', // Required when using custom wsHost/wsPort
|
|
1060
|
-
forceTLS: this.config.pusherConfig?.forceTLS ?? forceTLS,
|
|
1061
|
-
encrypted: this.config.pusherConfig?.encrypted ?? encrypted,
|
|
1062
|
-
disableStats: this.config.pusherConfig?.disableStats ?? true,
|
|
1063
|
-
enabledTransports: this.config.pusherConfig?.enabledTransports || ['ws', 'wss'],
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
console.log('[SDK]: Initializing Pusher with config:', pusherOptions)
|
|
1067
|
-
console.log('[SDK]: Pusher app key:', pusherAppKey ? `${pusherAppKey.substring(0, 10)}...` : 'not set')
|
|
1068
|
-
console.log('[SDK]: Pusher config source:', pusherConfigFromBranding ? 'branding API' : (this.config.pusherAppKey ? 'constructor config' : 'default'))
|
|
1069
|
-
const pusher = new Pusher(pusherAppKey, pusherOptions)
|
|
1070
|
-
|
|
1071
|
-
// Use signatureId as the channel name (Soketi doesn't need 'public-' prefix)
|
|
1072
|
-
const channel = pusher.subscribe(signatureId)
|
|
1073
|
-
console.log('[SDK]: Attempting to subscribe to Pusher channel:', signatureId)
|
|
1074
|
-
|
|
1075
|
-
// Log when channel subscription succeeds and bind events
|
|
1076
|
-
channel.bind('pusher:subscription_succeeded', () => {
|
|
1077
|
-
console.log('[SDK]: ✅ Successfully subscribed to channel:', signatureId)
|
|
1078
|
-
|
|
1079
|
-
// Bind countdown events after subscription succeeds (backend uses client-* prefix)
|
|
1080
|
-
channel.bind('countdown_update', (data: any) => {
|
|
1081
|
-
console.log('[SDK]: 📊 Received countdown_update event:', data)
|
|
1082
|
-
})
|
|
1083
|
-
channel.bind('client-countdown_update', (data: any) => {
|
|
1084
|
-
console.log('[SDK]: 📊 Received client-countdown_update event:', data)
|
|
1085
|
-
})
|
|
1086
|
-
|
|
1087
|
-
channel.bind('countdown_expired', () => {
|
|
1088
|
-
console.log('[SDK]: ⏰ Received countdown_expired event')
|
|
1089
|
-
})
|
|
1090
|
-
channel.bind('client-countdown_expired', () => {
|
|
1091
|
-
console.log('[SDK]: ⏰ Received client-countdown_expired event')
|
|
1092
|
-
})
|
|
1093
|
-
})
|
|
1094
|
-
|
|
1095
|
-
// Store handlers in a variable that will be set later
|
|
1096
|
-
let handleCountdownUpdate: ((data: any) => void) | null = null
|
|
1097
|
-
let handleCountdownExpired: (() => void) | null = null
|
|
1098
|
-
|
|
1099
|
-
// Bind events early - handlers will be assigned after QR setup
|
|
1100
|
-
const earlyCountdownHandler = (data: any) => {
|
|
1101
|
-
console.log('[SDK]: 📊 Received countdown_update event (early bind):', data)
|
|
1102
|
-
if (handleCountdownUpdate) {
|
|
1103
|
-
handleCountdownUpdate(data)
|
|
1104
|
-
} else {
|
|
1105
|
-
console.warn('[SDK]: Countdown handler not ready yet, storing event')
|
|
1106
|
-
// Store event to process once handler is ready
|
|
1107
|
-
setTimeout(() => {
|
|
1108
|
-
if (handleCountdownUpdate) {
|
|
1109
|
-
handleCountdownUpdate(data)
|
|
1110
|
-
}
|
|
1111
|
-
}, 100)
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
channel.bind('countdown_update', earlyCountdownHandler)
|
|
1115
|
-
channel.bind('client-countdown_update', earlyCountdownHandler) // Backend sends as client event
|
|
1116
|
-
|
|
1117
|
-
const earlyExpiredHandler = () => {
|
|
1118
|
-
console.log('[SDK]: ⏰ Received countdown_expired event (early bind)')
|
|
1119
|
-
if (handleCountdownExpired) {
|
|
1120
|
-
handleCountdownExpired()
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
channel.bind('countdown_expired', earlyExpiredHandler)
|
|
1124
|
-
channel.bind('client-countdown_expired', earlyExpiredHandler) // Backend sends as client event
|
|
1125
|
-
|
|
1126
|
-
// Log connection errors
|
|
1127
|
-
pusher.connection.bind('error', (err: any) => {
|
|
1128
|
-
console.error('[SDK]: ❌ Pusher connection error:', err)
|
|
1129
|
-
})
|
|
1130
|
-
|
|
1131
|
-
// Log state changes
|
|
1132
|
-
pusher.connection.bind('state_change', (states: any) => {
|
|
1133
|
-
console.log('[SDK]: 🔄 Pusher state change:', states.previous, '->', states.current)
|
|
1134
|
-
})
|
|
1135
|
-
|
|
1136
|
-
let counterpartyConnected = false
|
|
1137
|
-
let timeout: NodeJS.Timeout
|
|
1138
|
-
|
|
1139
|
-
// Wait for Pusher connection
|
|
1140
|
-
pusher.connection.bind('connected', () => {
|
|
1141
|
-
console.log('[SDK]: Pusher connected, waiting for counterparty...')
|
|
1142
|
-
|
|
1143
|
-
// Notify server that desktop device connected (if QR is shown inline, already sent above)
|
|
1144
|
-
if (!targetElement || !qrContainer) {
|
|
1145
|
-
channel.trigger('client-device_connected', { type: 'device_connected', deviceType: 'desktop' })
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// Timeout after 5 minutes
|
|
1149
|
-
timeout = setTimeout(() => {
|
|
1150
|
-
cleanup()
|
|
1151
|
-
pusher.disconnect()
|
|
1152
|
-
reject(new Error('WebSocket handoff timeout - no counterparty connected'))
|
|
1153
|
-
}, 5 * 60 * 1000)
|
|
1154
|
-
})
|
|
1155
|
-
|
|
1156
|
-
// Listen for mobile device connection (client event from sign page)
|
|
1157
|
-
// Device B now fetches payload from API, so we just mark as connected
|
|
1158
|
-
channel.bind('client-device_connected', (data: any) => {
|
|
1159
|
-
// Only react to mobile device connections (ignore our own desktop connection)
|
|
1160
|
-
if (data.type === 'device_connected' && data.deviceType === 'mobile' && !counterpartyConnected) {
|
|
1161
|
-
counterpartyConnected = true
|
|
1162
|
-
console.log('[SDK]: Mobile device connected (payload will be fetched from API)')
|
|
1163
|
-
// No longer sending payload via socket - Device B fetches from API
|
|
1164
|
-
}
|
|
1165
|
-
})
|
|
1166
|
-
|
|
1167
|
-
// Listen for signature result (client event from mobile)
|
|
1168
|
-
channel.bind('client-signature_result', (data: any) => {
|
|
1169
|
-
console.log('[SDK]: Signature result event received, raw data:', data)
|
|
1170
|
-
console.log('[SDK]: Data keys:', Object.keys(data || {}))
|
|
1171
|
-
console.log('[SDK]: counterpartyConnected:', counterpartyConnected)
|
|
1172
|
-
|
|
1173
|
-
// Extract signature from data - handle both direct and wrapped formats
|
|
1174
|
-
const signature = data?.signature || data?.data?.signature
|
|
1175
|
-
const error = data?.error || data?.data?.error
|
|
1176
|
-
const keyId = data?.keyId || data?.data?.keyId
|
|
1177
|
-
|
|
1178
|
-
console.log('[SDK]: Extracted values - signature:', !!signature, 'error:', error, 'keyId:', keyId)
|
|
1179
|
-
console.log('[SDK]: Full socket data received:', JSON.stringify(data, null, 2))
|
|
1180
|
-
|
|
1181
|
-
// Prevent multiple processing
|
|
1182
|
-
if (!counterpartyConnected) {
|
|
1183
|
-
console.warn('[SDK]: Received signature result but counterparty not marked as connected yet. Waiting...')
|
|
1184
|
-
// Don't return - might be a timing issue, try to process anyway
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
clearTimeout(timeout)
|
|
1188
|
-
|
|
1189
|
-
if (error) {
|
|
1190
|
-
console.error('[SDK]: Signature error:', error)
|
|
1191
|
-
cleanup()
|
|
1192
|
-
pusher.disconnect()
|
|
1193
|
-
reject(new Error(error))
|
|
1194
|
-
return
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
if (signature) {
|
|
1198
|
-
console.log('[SDK]: Signature received successfully, resolving promise...')
|
|
1199
|
-
// Use keyId from Device B (not localStorage) - Device B sent this via socket
|
|
1200
|
-
const deviceBKeyId = keyId || ''
|
|
1201
|
-
if (!deviceBKeyId) {
|
|
1202
|
-
console.warn('[SDK]: ⚠️ WARNING - No keyId received from Device B via socket! Data:', data)
|
|
1203
|
-
}
|
|
1204
|
-
console.log('[SDK]: ✅ Using keyId from Device B:', deviceBKeyId, '(extracted from socket)')
|
|
1205
|
-
|
|
1206
|
-
// Store handoff keyId in localStorage (Device A - desktop)
|
|
1207
|
-
if (deviceBKeyId && this.brandingCache?.authFlowId) {
|
|
1208
|
-
const authFlowId = this.brandingCache.authFlowId
|
|
1209
|
-
const handoffStorageKey = `trulyYouKeyId_${authFlowId}_handoff`
|
|
1210
|
-
|
|
1211
|
-
// Store in same-origin localStorage
|
|
1212
|
-
if (typeof window !== 'undefined') {
|
|
1213
|
-
localStorage.setItem(handoffStorageKey, deviceBKeyId)
|
|
1214
|
-
console.log('[SDK]: ✅ Stored handoff keyId in localStorage:', handoffStorageKey, '=', deviceBKeyId)
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
// Also store in TrulyYou frontend's localStorage via iframe
|
|
1218
|
-
try {
|
|
1219
|
-
const frontendUrl = this.frontendUrl
|
|
1220
|
-
const iframe = document.createElement('iframe')
|
|
1221
|
-
iframe.style.cssText = 'position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;'
|
|
1222
|
-
|
|
1223
|
-
const storeUrl = new URL(`${frontendUrl}/store-handoff-keyid.html`)
|
|
1224
|
-
storeUrl.searchParams.set('authFlowId', authFlowId)
|
|
1225
|
-
storeUrl.searchParams.set('keyId', deviceBKeyId)
|
|
1226
|
-
|
|
1227
|
-
iframe.src = storeUrl.toString()
|
|
1228
|
-
document.body.appendChild(iframe)
|
|
1229
|
-
|
|
1230
|
-
// Clean up after a delay
|
|
1231
|
-
setTimeout(() => {
|
|
1232
|
-
if (iframe.parentNode) {
|
|
1233
|
-
iframe.parentNode.removeChild(iframe)
|
|
1234
|
-
}
|
|
1235
|
-
}, 2000)
|
|
1236
|
-
|
|
1237
|
-
console.log('[SDK]: ✅ Stored handoff keyId in TrulyYou frontend localStorage via iframe')
|
|
1238
|
-
} catch (error) {
|
|
1239
|
-
console.warn('[SDK]: Failed to store handoff keyId in TrulyYou frontend localStorage:', error)
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
const result: SigningResult = {
|
|
1244
|
-
signature: signature,
|
|
1245
|
-
keyId: deviceBKeyId,
|
|
1246
|
-
signatureId: signatureId
|
|
1247
|
-
}
|
|
1248
|
-
console.log('[SDK]: Resolving with SigningResult:', {
|
|
1249
|
-
signatureLength: result.signature?.length,
|
|
1250
|
-
keyId: result.keyId,
|
|
1251
|
-
signatureId: result.signatureId,
|
|
1252
|
-
keyIdSource: deviceBKeyId ? 'Device B (socket)' : 'MISSING - will fallback to localStorage'
|
|
1253
|
-
})
|
|
1254
|
-
|
|
1255
|
-
cleanup()
|
|
1256
|
-
pusher.disconnect()
|
|
1257
|
-
resolve(result)
|
|
1258
|
-
return
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
console.error('[SDK]: Invalid signature result data - no signature or error:', JSON.stringify(data))
|
|
1262
|
-
cleanup()
|
|
1263
|
-
pusher.disconnect()
|
|
1264
|
-
reject(new Error('Invalid signature result received'))
|
|
1265
|
-
})
|
|
1266
|
-
|
|
1267
|
-
pusher.connection.bind('error', (error: any) => {
|
|
1268
|
-
console.error('[SDK]: Pusher connection error:', error)
|
|
1269
|
-
clearTimeout(timeout)
|
|
1270
|
-
pusher.disconnect()
|
|
1271
|
-
reject(new Error('Pusher connection error'))
|
|
1272
|
-
})
|
|
1273
|
-
|
|
1274
|
-
pusher.connection.bind('disconnected', () => {
|
|
1275
|
-
console.log('[SDK]: Pusher disconnected')
|
|
1276
|
-
clearTimeout(timeout)
|
|
1277
|
-
})
|
|
1278
|
-
|
|
1279
|
-
// Store signatureId and signUrl for enrollment page to use
|
|
1280
|
-
// This will be handled by opening enrollment page with signatureId
|
|
1281
|
-
// For now, we'll need to modify the enrollment flow to handle this
|
|
1282
|
-
|
|
1283
|
-
// Actually, we need to open enrollment page instead of sign page
|
|
1284
|
-
// The enrollment page will show QR code and handle WebSocket
|
|
1285
|
-
// Let's modify the approach - we'll emit an event that the app can listen to
|
|
1286
|
-
|
|
1287
|
-
// Store attestation data in sessionStorage for sign page to access
|
|
1288
|
-
sessionStorage.setItem(`signHandoff_${signatureId}`, JSON.stringify(attestationData))
|
|
1289
|
-
|
|
1290
|
-
// Show QR code inline in target element (Device A - desktop)
|
|
1291
|
-
// Device B will scan QR code and open the sign page URL
|
|
1292
|
-
const targetElement = this.resolveTargetElement()
|
|
1293
|
-
let qrContainer: HTMLElement | null = null
|
|
1294
|
-
let checkWindowClosed: NodeJS.Timeout | null = null
|
|
1295
|
-
|
|
1296
|
-
if (targetElement) {
|
|
1297
|
-
// Create QR code container
|
|
1298
|
-
qrContainer = document.createElement('div')
|
|
1299
|
-
qrContainer.id = `qr-handoff-${signatureId}`
|
|
1300
|
-
qrContainer.style.cssText = `
|
|
1301
|
-
position: absolute;
|
|
1302
|
-
top: 0;
|
|
1303
|
-
left: 0;
|
|
1304
|
-
right: 0;
|
|
1305
|
-
bottom: 0;
|
|
1306
|
-
width: 100%;
|
|
1307
|
-
height: 100%;
|
|
1308
|
-
max-width: 100%;
|
|
1309
|
-
max-height: 100%;
|
|
1310
|
-
overflow: hidden;
|
|
1311
|
-
background-color: rgba(255, 255, 255, 0.98);
|
|
1312
|
-
display: flex;
|
|
1313
|
-
flex-direction: column;
|
|
1314
|
-
align-items: center;
|
|
1315
|
-
justify-content: center;
|
|
1316
|
-
z-index: 10001;
|
|
1317
|
-
padding: 0;
|
|
1318
|
-
box-sizing: border-box;
|
|
1319
|
-
`
|
|
1320
|
-
|
|
1321
|
-
// Create QR code wrapper with countdown SVG overlay
|
|
1322
|
-
const qrWrapper = document.createElement('div')
|
|
1323
|
-
qrWrapper.style.cssText = `
|
|
1324
|
-
position: relative;
|
|
1325
|
-
display: flex;
|
|
1326
|
-
align-items: center;
|
|
1327
|
-
justify-content: center;
|
|
1328
|
-
width: 100%;
|
|
1329
|
-
height: 100%;
|
|
1330
|
-
max-width: 100%;
|
|
1331
|
-
max-height: 100%;
|
|
1332
|
-
`
|
|
1333
|
-
|
|
1334
|
-
// Create loading spinner (shown while QR loads)
|
|
1335
|
-
const spinnerContainer = document.createElement('div')
|
|
1336
|
-
spinnerContainer.style.cssText = `
|
|
1337
|
-
position: absolute;
|
|
1338
|
-
top: 50%;
|
|
1339
|
-
left: 50%;
|
|
1340
|
-
transform: translate(-50%, -50%);
|
|
1341
|
-
width: 48px;
|
|
1342
|
-
height: 48px;
|
|
1343
|
-
z-index: 1;
|
|
1344
|
-
display: flex;
|
|
1345
|
-
align-items: center;
|
|
1346
|
-
justify-content: center;
|
|
1347
|
-
`
|
|
1348
|
-
|
|
1349
|
-
const spinner = document.createElement('div')
|
|
1350
|
-
spinner.style.cssText = `
|
|
1351
|
-
width: 48px;
|
|
1352
|
-
height: 48px;
|
|
1353
|
-
border: 4px solid ${this.brandingCache?.primary ? `${this.brandingCache.primary}20` : '#e5e7eb'};
|
|
1354
|
-
border-top-color: ${this.brandingCache?.primary || '#2563eb'};
|
|
1355
|
-
border-radius: 50%;
|
|
1356
|
-
animation: spin 1s linear infinite;
|
|
1357
|
-
`
|
|
1358
|
-
|
|
1359
|
-
// Add spin animation
|
|
1360
|
-
const style = document.createElement('style')
|
|
1361
|
-
style.textContent = `
|
|
1362
|
-
@keyframes spin {
|
|
1363
|
-
to { transform: rotate(360deg); }
|
|
1364
|
-
}
|
|
1365
|
-
`
|
|
1366
|
-
document.head.appendChild(style)
|
|
1367
|
-
|
|
1368
|
-
spinnerContainer.appendChild(spinner)
|
|
1369
|
-
|
|
1370
|
-
// Create QR code with app icon overlay in center
|
|
1371
|
-
const qrImage = document.createElement('img')
|
|
1372
|
-
qrImage.alt = 'QR Code for mobile signing (click to copy URL)'
|
|
1373
|
-
qrImage.style.cssText = `
|
|
1374
|
-
width: 100%;
|
|
1375
|
-
height: 100%;
|
|
1376
|
-
max-width: 100%;
|
|
1377
|
-
max-height: 100%;
|
|
1378
|
-
aspect-ratio: 1;
|
|
1379
|
-
object-fit: contain;
|
|
1380
|
-
cursor: pointer;
|
|
1381
|
-
transition: opacity 0.2s;
|
|
1382
|
-
position: relative;
|
|
1383
|
-
z-index: 2;
|
|
1384
|
-
display: none;
|
|
1385
|
-
opacity: 0;
|
|
1386
|
-
`
|
|
1387
|
-
qrImage.title = 'Click to copy URL to clipboard'
|
|
1388
|
-
|
|
1389
|
-
// Show spinner initially, hide when QR loads
|
|
1390
|
-
qrImage.addEventListener('load', () => {
|
|
1391
|
-
console.log('[SDK]: QR image loaded')
|
|
1392
|
-
spinnerContainer.style.display = 'none'
|
|
1393
|
-
qrImage.style.display = 'block'
|
|
1394
|
-
qrImage.style.opacity = '1'
|
|
1395
|
-
// Recalculate SVG position after QR is loaded and visible
|
|
1396
|
-
setTimeout(() => {
|
|
1397
|
-
if (setupCountdownRect) {
|
|
1398
|
-
setupCountdownRect()
|
|
1399
|
-
}
|
|
1400
|
-
}, 50)
|
|
1401
|
-
})
|
|
1402
|
-
|
|
1403
|
-
qrImage.addEventListener('error', () => {
|
|
1404
|
-
console.error('[SDK]: QR image failed to load')
|
|
1405
|
-
spinnerContainer.style.display = 'none'
|
|
1406
|
-
// Still try to show QR even if load failed
|
|
1407
|
-
qrImage.style.display = 'block'
|
|
1408
|
-
qrImage.style.opacity = '0.5'
|
|
1409
|
-
})
|
|
1410
|
-
|
|
1411
|
-
// Create countdown SVG overlay (positioned absolutely over QR image, matching exact size)
|
|
1412
|
-
const countdownSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
1413
|
-
countdownSvg.id = `countdown-svg-${signatureId}`
|
|
1414
|
-
|
|
1415
|
-
// Wait for QR image to load and get actual rendered dimensions, then size SVG to match exactly
|
|
1416
|
-
const setupCountdownRect = () => {
|
|
1417
|
-
// Get actual rendered size and position of QR image (after object-fit: contain is applied)
|
|
1418
|
-
const qrRect = qrImage.getBoundingClientRect()
|
|
1419
|
-
const qrWrapperRect = qrWrapper.getBoundingClientRect()
|
|
1420
|
-
|
|
1421
|
-
// QR image with object-fit: contain and aspect-ratio: 1 will be square
|
|
1422
|
-
// and fit inside the wrapper. Get its actual rendered size.
|
|
1423
|
-
const actualQRSize = Math.min(qrRect.width, qrRect.height)
|
|
1424
|
-
|
|
1425
|
-
// Make SVG slightly bigger than QR to create border space (5% larger on each side = 10% total)
|
|
1426
|
-
const borderPadding = actualQRSize * 0.05 // 5% padding on each side
|
|
1427
|
-
const svgSize = actualQRSize + (borderPadding * 2) // Total SVG size with border space
|
|
1428
|
-
|
|
1429
|
-
// Calculate position relative to wrapper (not viewport)
|
|
1430
|
-
// Use getBoundingClientRect for accurate positioning (accounts for transforms and object-fit)
|
|
1431
|
-
// For centered QR with flexbox, calculate offset from QR center to wrapper center
|
|
1432
|
-
const qrCenterX = qrRect.left + qrRect.width / 2 - qrWrapperRect.left
|
|
1433
|
-
const qrCenterY = qrRect.top + qrRect.height / 2 - qrWrapperRect.top
|
|
1434
|
-
|
|
1435
|
-
// SVG should be centered over QR but slightly bigger - position from top-left
|
|
1436
|
-
const offsetX = qrCenterX - svgSize / 2
|
|
1437
|
-
const offsetY = qrCenterY - svgSize / 2
|
|
1438
|
-
|
|
1439
|
-
// Use getBoundingClientRect values as they account for transforms and positioning
|
|
1440
|
-
console.log('[SDK]: QR positioning - centered offset:', offsetX, offsetY, 'QR size:', actualQRSize, 'SVG size:', svgSize)
|
|
1441
|
-
|
|
1442
|
-
// Position and size SVG to be slightly bigger than QR (with border space)
|
|
1443
|
-
countdownSvg.style.cssText = `
|
|
1444
|
-
position: absolute;
|
|
1445
|
-
top: ${offsetY}px;
|
|
1446
|
-
left: ${offsetX}px;
|
|
1447
|
-
width: ${svgSize}px;
|
|
1448
|
-
height: ${svgSize}px;
|
|
1449
|
-
pointer-events: none;
|
|
1450
|
-
z-index: 2;
|
|
1451
|
-
transform: rotate(0deg);
|
|
1452
|
-
transform-origin: center center;
|
|
1453
|
-
`
|
|
1454
|
-
countdownSvg.setAttribute('viewBox', `0 0 ${svgSize} ${svgSize}`)
|
|
1455
|
-
countdownSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
|
1456
|
-
|
|
1457
|
-
// Rounded rectangle parameters - use percentages relative to viewBox
|
|
1458
|
-
// The SVG is bigger than QR to create border space, but rect should fit within SVG bounds
|
|
1459
|
-
const rectWidth = svgSize
|
|
1460
|
-
const rectHeight = svgSize
|
|
1461
|
-
const cornerRadius = svgSize * 0.04 // 4% of size for rounded corners (scales with QR)
|
|
1462
|
-
const strokeWidth = svgSize * 0.06 // 6% of size for stroke (3x thicker - scales with QR)
|
|
1463
|
-
|
|
1464
|
-
// Clear existing rects if any
|
|
1465
|
-
while (countdownSvg.firstChild) {
|
|
1466
|
-
countdownSvg.removeChild(countdownSvg.firstChild)
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
// Position rect so stroke sits OUTSIDE the QR image
|
|
1470
|
-
// QR is centered in SVG, so it goes from borderPadding to svgSize - borderPadding
|
|
1471
|
-
// We want the stroke to sit outside this, so:
|
|
1472
|
-
// - Rect inner edge should be at borderPadding (QR edge)
|
|
1473
|
-
// - Stroke is centered on path, so x = borderPadding - strokeWidth/2 (so stroke extends outside)
|
|
1474
|
-
// - Rect width should be actualQRSize + strokeWidth (to account for stroke extending outside)
|
|
1475
|
-
const rectX = borderPadding - strokeWidth / 2
|
|
1476
|
-
const rectY = borderPadding - strokeWidth / 2
|
|
1477
|
-
const rectW = actualQRSize + strokeWidth
|
|
1478
|
-
const rectH = actualQRSize + strokeWidth
|
|
1479
|
-
|
|
1480
|
-
// Background rounded rectangle outline - positioned OUTSIDE QR bounds
|
|
1481
|
-
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
|
1482
|
-
bgRect.setAttribute('x', rectX.toString())
|
|
1483
|
-
bgRect.setAttribute('y', rectY.toString())
|
|
1484
|
-
bgRect.setAttribute('width', rectW.toString())
|
|
1485
|
-
bgRect.setAttribute('height', rectH.toString())
|
|
1486
|
-
bgRect.setAttribute('rx', cornerRadius.toString())
|
|
1487
|
-
bgRect.setAttribute('ry', cornerRadius.toString())
|
|
1488
|
-
bgRect.setAttribute('fill', 'none')
|
|
1489
|
-
bgRect.setAttribute('stroke', '#e5e7eb')
|
|
1490
|
-
bgRect.setAttribute('stroke-width', strokeWidth.toString())
|
|
1491
|
-
countdownSvg.appendChild(bgRect)
|
|
1492
|
-
|
|
1493
|
-
// Progress rounded rectangle outline (animated) - same positioning, OUTSIDE QR bounds
|
|
1494
|
-
const progressRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
|
1495
|
-
progressRect.id = `countdown-progress-${signatureId}`
|
|
1496
|
-
progressRect.setAttribute('x', rectX.toString())
|
|
1497
|
-
progressRect.setAttribute('y', rectY.toString())
|
|
1498
|
-
progressRect.setAttribute('width', rectW.toString())
|
|
1499
|
-
progressRect.setAttribute('height', rectH.toString())
|
|
1500
|
-
progressRect.setAttribute('rx', cornerRadius.toString())
|
|
1501
|
-
progressRect.setAttribute('ry', cornerRadius.toString())
|
|
1502
|
-
progressRect.setAttribute('fill', 'none')
|
|
1503
|
-
progressRect.setAttribute('stroke', this.brandingCache?.primary || '#2563eb')
|
|
1504
|
-
progressRect.setAttribute('stroke-width', strokeWidth.toString())
|
|
1505
|
-
progressRect.setAttribute('stroke-linecap', 'round')
|
|
1506
|
-
progressRect.setAttribute('stroke-linejoin', 'round')
|
|
1507
|
-
// Recalculate perimeter with new dimensions (rect is outside QR bounds)
|
|
1508
|
-
const actualInnerWidth = rectW
|
|
1509
|
-
const actualInnerHeight = rectH
|
|
1510
|
-
const actualInnerRx = cornerRadius
|
|
1511
|
-
const actualStraightEdges = 2 * (actualInnerWidth + actualInnerHeight - 2 * actualInnerRx)
|
|
1512
|
-
const actualCornerArcs = 2 * Math.PI * actualInnerRx
|
|
1513
|
-
const actualPerimeter = actualStraightEdges + actualCornerArcs
|
|
1514
|
-
progressRect.setAttribute('stroke-dasharray', actualPerimeter.toString())
|
|
1515
|
-
progressRect.setAttribute('stroke-dashoffset', actualPerimeter.toString())
|
|
1516
|
-
progressRect.style.transition = 'stroke-dashoffset 0.3s linear, stroke 0.3s linear'
|
|
1517
|
-
countdownSvg.appendChild(progressRect)
|
|
1518
|
-
|
|
1519
|
-
// Return actual perimeter for use in countdown updates
|
|
1520
|
-
return { perimeter: actualPerimeter }
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
// Setup when image loads or immediately if already loaded
|
|
1524
|
-
if (qrImage.complete && qrImage.naturalWidth > 0) {
|
|
1525
|
-
setupCountdownRect()
|
|
1526
|
-
} else {
|
|
1527
|
-
qrImage.addEventListener('load', () => {
|
|
1528
|
-
setupCountdownRect()
|
|
1529
|
-
})
|
|
1530
|
-
// Fallback setup with default values
|
|
1531
|
-
setupCountdownRect()
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
// Countdown state
|
|
1535
|
-
let countdownState = { remaining: 30, total: 30 }
|
|
1536
|
-
|
|
1537
|
-
// Listen for countdown updates (bound both early and in subscription_succeeded)
|
|
1538
|
-
// Assign handler to the stored variable so early binds can access it
|
|
1539
|
-
handleCountdownUpdate = (data: any) => {
|
|
1540
|
-
console.log('[SDK]: 🎯 Processing countdown_update:', data)
|
|
1541
|
-
if (data && (data.type === 'countdown_update' || data.timeRemaining !== undefined)) {
|
|
1542
|
-
countdownState = {
|
|
1543
|
-
remaining: data.timeRemaining ?? countdownState.remaining,
|
|
1544
|
-
total: data.totalTime ?? countdownState.total
|
|
1545
|
-
}
|
|
1546
|
-
console.log(`[SDK]: 📊 Updating countdown rectangle: ${countdownState.remaining}/${countdownState.total}`)
|
|
1547
|
-
|
|
1548
|
-
// Calculate perimeter using actual SVG size from viewBox
|
|
1549
|
-
const viewBoxAttr = countdownSvg.getAttribute('viewBox')
|
|
1550
|
-
const svgSize = viewBoxAttr ? parseFloat(viewBoxAttr.split(' ')[2]) || 100 : 100
|
|
1551
|
-
const cornerRadius = svgSize * 0.04
|
|
1552
|
-
const strokeWidth = svgSize * 0.06 // 3x thicker
|
|
1553
|
-
// Calculate border padding (5% of QR size, but we need to reverse-calculate from SVG size)
|
|
1554
|
-
// SVG = QR + 2*borderPadding, so borderPadding = (SVG - QR) / 2
|
|
1555
|
-
// We approximate QR as ~91% of SVG (since SVG = QR * 1.1, so QR ≈ SVG / 1.1)
|
|
1556
|
-
const estimatedQRSize = svgSize / 1.1
|
|
1557
|
-
const borderPadding = (svgSize - estimatedQRSize) / 2
|
|
1558
|
-
// Rectangle is positioned OUTSIDE QR (at borderPadding - strokeWidth/2)
|
|
1559
|
-
const rectW = estimatedQRSize + strokeWidth
|
|
1560
|
-
const rectH = estimatedQRSize + strokeWidth
|
|
1561
|
-
const actualInnerWidth = rectW
|
|
1562
|
-
const actualInnerHeight = rectH
|
|
1563
|
-
const actualInnerRx = cornerRadius
|
|
1564
|
-
const straightEdges = 2 * (actualInnerWidth + actualInnerHeight - 2 * actualInnerRx)
|
|
1565
|
-
const cornerArcs = 2 * Math.PI * actualInnerRx
|
|
1566
|
-
const currentPerimeter = straightEdges + cornerArcs
|
|
1567
|
-
|
|
1568
|
-
const percentage = countdownState.remaining / countdownState.total
|
|
1569
|
-
const strokeDashoffset = currentPerimeter * (1 - percentage)
|
|
1570
|
-
|
|
1571
|
-
// Update the progress rect if it exists
|
|
1572
|
-
const progressRectElement = document.getElementById(`countdown-progress-${signatureId}`) as SVGElement | null
|
|
1573
|
-
if (progressRectElement && progressRectElement instanceof SVGRectElement) {
|
|
1574
|
-
progressRectElement.setAttribute('stroke-dashoffset', strokeDashoffset.toString())
|
|
1575
|
-
console.log(`[SDK]: 📊 Set stroke-dashoffset to: ${strokeDashoffset} (percentage: ${percentage}, perimeter: ${currentPerimeter})`)
|
|
1576
|
-
|
|
1577
|
-
// Change color when low
|
|
1578
|
-
if (countdownState.remaining <= 5) {
|
|
1579
|
-
progressRectElement.setAttribute('stroke', '#ef4444')
|
|
1580
|
-
} else if (countdownState.remaining <= 10) {
|
|
1581
|
-
progressRectElement.setAttribute('stroke', '#f59e0b')
|
|
1582
|
-
} else {
|
|
1583
|
-
progressRectElement.setAttribute('stroke', this.brandingCache?.primary || '#2563eb')
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
} else {
|
|
1587
|
-
console.warn('[SDK]: Invalid countdown_update data:', data)
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
handleCountdownExpired = () => {
|
|
1592
|
-
console.log('[SDK]: ⏰ Countdown expired - cleaning up and resetting state')
|
|
1593
|
-
|
|
1594
|
-
// Clean up Pusher connection first
|
|
1595
|
-
if (pusher && pusher.connection.state === 'connected') {
|
|
1596
|
-
pusher.disconnect()
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
// Remove QR container and all its children completely
|
|
1600
|
-
if (qrContainer) {
|
|
1601
|
-
// Remove all child elements
|
|
1602
|
-
while (qrContainer.firstChild) {
|
|
1603
|
-
qrContainer.removeChild(qrContainer.firstChild)
|
|
1604
|
-
}
|
|
1605
|
-
// Remove container from DOM
|
|
1606
|
-
if (qrContainer.parentNode) {
|
|
1607
|
-
qrContainer.parentNode.removeChild(qrContainer)
|
|
1608
|
-
}
|
|
1609
|
-
qrContainer = null
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
// Reset countdown SVG reference
|
|
1613
|
-
const countdownSvgElement = document.getElementById(`countdown-svg-${signatureId}`)
|
|
1614
|
-
if (countdownSvgElement && countdownSvgElement.parentNode) {
|
|
1615
|
-
countdownSvgElement.parentNode.removeChild(countdownSvgElement)
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
// Clean up any other references
|
|
1619
|
-
cleanup()
|
|
1620
|
-
|
|
1621
|
-
// Silently reject - don't show alert, just reset state
|
|
1622
|
-
reject(new Error('Countdown expired'))
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
channel.bind('countdown_update', handleCountdownUpdate)
|
|
1626
|
-
channel.bind('client-countdown_update', handleCountdownUpdate) // Backend sends as client event
|
|
1627
|
-
channel.bind('countdown_expired', handleCountdownExpired)
|
|
1628
|
-
channel.bind('client-countdown_expired', handleCountdownExpired) // Backend sends as client event
|
|
1629
|
-
|
|
1630
|
-
qrWrapper.appendChild(spinnerContainer)
|
|
1631
|
-
qrWrapper.appendChild(qrImage)
|
|
1632
|
-
qrWrapper.appendChild(countdownSvg)
|
|
1633
|
-
|
|
1634
|
-
// Add click handler to copy URL to clipboard
|
|
1635
|
-
qrImage.addEventListener('click', async () => {
|
|
1636
|
-
try {
|
|
1637
|
-
await navigator.clipboard.writeText(signUrl)
|
|
1638
|
-
console.log('[SDK]: URL copied to clipboard:', signUrl)
|
|
1639
|
-
|
|
1640
|
-
// Visual feedback - temporarily change opacity
|
|
1641
|
-
const originalOpacity = qrImage.style.opacity || '1'
|
|
1642
|
-
qrImage.style.opacity = '0.7'
|
|
1643
|
-
setTimeout(() => {
|
|
1644
|
-
qrImage.style.opacity = originalOpacity
|
|
1645
|
-
}, 200)
|
|
1646
|
-
} catch (err) {
|
|
1647
|
-
console.error('[SDK]: Failed to copy URL to clipboard:', err)
|
|
1648
|
-
// Fallback for older browsers
|
|
1649
|
-
const textArea = document.createElement('textarea')
|
|
1650
|
-
textArea.value = signUrl
|
|
1651
|
-
textArea.style.position = 'fixed'
|
|
1652
|
-
textArea.style.opacity = '0'
|
|
1653
|
-
document.body.appendChild(textArea)
|
|
1654
|
-
textArea.select()
|
|
1655
|
-
try {
|
|
1656
|
-
document.execCommand('copy')
|
|
1657
|
-
console.log('[SDK]: URL copied to clipboard (fallback method)')
|
|
1658
|
-
} catch (fallbackErr) {
|
|
1659
|
-
console.error('[SDK]: Fallback copy failed:', fallbackErr)
|
|
1660
|
-
}
|
|
1661
|
-
document.body.removeChild(textArea)
|
|
1662
|
-
}
|
|
1663
|
-
})
|
|
1664
|
-
|
|
1665
|
-
// Generate QR code with icon overlay
|
|
1666
|
-
this.generateQRCodeWithIcon(signUrl, qrImage).catch((err: any) => {
|
|
1667
|
-
console.warn('[SDK]: Error generating QR code with icon, using fallback:', err)
|
|
1668
|
-
// Fallback to simple QR code without icon
|
|
1669
|
-
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(signUrl)}`
|
|
1670
|
-
qrImage.src = qrCodeUrl
|
|
1671
|
-
})
|
|
1672
|
-
|
|
1673
|
-
qrContainer.appendChild(qrWrapper)
|
|
1674
|
-
|
|
1675
|
-
// Make target element position relative if needed and add overflow constraints
|
|
1676
|
-
const targetStyle = window.getComputedStyle(targetElement)
|
|
1677
|
-
if (targetStyle.position === 'static') {
|
|
1678
|
-
targetElement.style.position = 'relative'
|
|
1679
|
-
}
|
|
1680
|
-
// Ensure target element contains the overlay
|
|
1681
|
-
if (targetStyle.overflow === 'visible') {
|
|
1682
|
-
targetElement.style.overflow = 'hidden'
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
targetElement.appendChild(qrContainer)
|
|
1686
|
-
} else {
|
|
1687
|
-
// No target element - fallback to opening in new window
|
|
1688
|
-
console.warn('[SDK]: No target element available, opening sign page in new window')
|
|
1689
|
-
const signWindow = window.open(signUrl, '_blank', 'width=600,height=700')
|
|
1690
|
-
|
|
1691
|
-
if (!signWindow) {
|
|
1692
|
-
pusher.disconnect()
|
|
1693
|
-
reject(new Error('Failed to open sign page. Please allow popups for this site.'))
|
|
1694
|
-
return
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
// Monitor sign window for closure
|
|
1698
|
-
checkWindowClosed = setInterval(() => {
|
|
1699
|
-
if (signWindow.closed) {
|
|
1700
|
-
if (checkWindowClosed) clearInterval(checkWindowClosed)
|
|
1701
|
-
clearTimeout(timeout)
|
|
1702
|
-
pusher.disconnect()
|
|
1703
|
-
reject(new Error('Sign window was closed'))
|
|
1704
|
-
}
|
|
1705
|
-
}, 500)
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
// Store pusher reference for cleanup
|
|
1709
|
-
let pusherInstance: Pusher | null = pusher
|
|
1710
|
-
|
|
1711
|
-
// Cleanup on completion
|
|
1712
|
-
const cleanup = () => {
|
|
1713
|
-
if (qrContainer && qrContainer.parentNode) {
|
|
1714
|
-
qrContainer.parentNode.removeChild(qrContainer)
|
|
1715
|
-
}
|
|
1716
|
-
if (checkWindowClosed) {
|
|
1717
|
-
clearInterval(checkWindowClosed)
|
|
1718
|
-
}
|
|
1719
|
-
clearTimeout(timeout)
|
|
1720
|
-
if (pusherInstance) {
|
|
1721
|
-
pusherInstance.disconnect()
|
|
1722
|
-
pusherInstance = null
|
|
1723
|
-
}
|
|
1724
|
-
sessionStorage.removeItem(`signHandoff_${signatureId}`)
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
} catch (error: any) {
|
|
1728
|
-
reject(error)
|
|
1729
|
-
}
|
|
1730
|
-
})
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
/**
|
|
1734
|
-
* Sign a payload with WebAuthn without a server-generated challenge
|
|
1735
|
-
* Encodes the payload directly as the challenge
|
|
1736
|
-
* Uses iframe to extract key from iframe's localStorage, not host
|
|
1737
|
-
*/
|
|
1738
|
-
async signPayload(apiCallStructure: {
|
|
1739
|
-
body: any
|
|
1740
|
-
uri: string
|
|
1741
|
-
method: string
|
|
1742
|
-
headers?: any
|
|
1743
|
-
}, signatureId?: string, existingKeyId?: string): Promise<SigningResult> {
|
|
1744
|
-
try {
|
|
1745
|
-
// Step 1: Check if desktop device first - desktop uses WebSocket handoff (no key probe needed)
|
|
1746
|
-
if (this.isDesktopDevice()) {
|
|
1747
|
-
console.log('[SDK]: Desktop device detected, using WebSocket handoff (skipping key probe)')
|
|
1748
|
-
if (!signatureId) {
|
|
1749
|
-
throw new Error('signatureId is required for desktop handoff')
|
|
1750
|
-
}
|
|
1751
|
-
return await this.signWithWebSocketHandoff(apiCallStructure, signatureId)
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
// Step 2: Mobile device - use existing keyId if provided, otherwise probe
|
|
1755
|
-
let keyId: string | null | undefined = existingKeyId
|
|
1756
|
-
|
|
1757
|
-
if (!keyId) {
|
|
1758
|
-
console.log('[SDK]: Mobile device detected, probing iframe for existing key...')
|
|
1759
|
-
keyId = await this.probeIframeForKey()
|
|
1760
|
-
|
|
1761
|
-
if (!keyId) {
|
|
1762
|
-
// Step 3: No valid active key found (missing, invalid, revoked, or expired) - open enrollment popup
|
|
1763
|
-
console.log('[SDK]: No valid active key found (missing, invalid, revoked, or expired), opening enrollment popup...')
|
|
1764
|
-
keyId = await this.enrollWithPopup()
|
|
1765
|
-
|
|
1766
|
-
if (!keyId) {
|
|
1767
|
-
throw new Error('Enrollment completed but no valid active key found')
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
} else {
|
|
1771
|
-
console.log('[SDK]: Using existing keyId from enrollment:', keyId)
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
console.log('[SDK]: Valid active key found, signing with keyId:', keyId)
|
|
1775
|
-
|
|
1776
|
-
// Step 5: Sign using iframe (key is extracted from iframe's localStorage)
|
|
1777
|
-
console.log('[SDK]: Using iframe signing for mobile device')
|
|
1778
|
-
return await this.signWithIframe(apiCallStructure)
|
|
1779
|
-
|
|
1780
|
-
} catch (error: any) {
|
|
1781
|
-
console.error('[SDK]: Signing error:', error)
|
|
1782
|
-
throw error
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
/**
|
|
1787
|
-
* Fetch with automatic payload signing
|
|
1788
|
-
*/
|
|
1789
|
-
async fetchWithSignature(
|
|
1790
|
-
url: string,
|
|
1791
|
-
options: FetchOptions = {}
|
|
1792
|
-
): Promise<FetchResult> {
|
|
1793
|
-
// Ensure authFlowId is loaded before any signing operations
|
|
1794
|
-
await this.ensureAuthFlowIdLoaded()
|
|
1795
|
-
|
|
1796
|
-
// Declare signatureId at function scope so it's accessible in catch block
|
|
1797
|
-
let signatureId: string | undefined = undefined
|
|
1798
|
-
try {
|
|
1799
|
-
console.log('[SDK]: fetchWithSignature called for:', url)
|
|
1800
|
-
|
|
1801
|
-
// Parse URL to get path and base URL
|
|
1802
|
-
const urlObj = new URL(url)
|
|
1803
|
-
const uriPath = urlObj.pathname + urlObj.search
|
|
1804
|
-
const baseUrl = `${urlObj.protocol}//${urlObj.host}`
|
|
1805
|
-
|
|
1806
|
-
// Prepare API call structure
|
|
1807
|
-
const apiCallStructure = {
|
|
1808
|
-
body: options.body ? JSON.parse(options.body as string) : {},
|
|
1809
|
-
uri: uriPath,
|
|
1810
|
-
method: (options.method || 'GET').toUpperCase(),
|
|
1811
|
-
headers: options.headers || {}
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
console.log('[SDK]: API call structure:', apiCallStructure)
|
|
1815
|
-
|
|
1816
|
-
// Check if domain + signature template match (if authAppId is configured)
|
|
1817
|
-
if (this.authAppId) {
|
|
1818
|
-
try {
|
|
1819
|
-
// Generate signatureId first (for desktop handoff, we need it before signing)
|
|
1820
|
-
// Generate a long, unique signatureId
|
|
1821
|
-
signatureId = `sig_${Date.now()}_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
|
1822
|
-
|
|
1823
|
-
// Get keyId using authFlowId
|
|
1824
|
-
let keyId: string | null = null
|
|
1825
|
-
if (this.brandingCache?.authFlowId) {
|
|
1826
|
-
keyId = this.getKeyIdByAuthFlowId(this.brandingCache.authFlowId)
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
const userIdStr = localStorage.getItem('trulyYouUserId')
|
|
1830
|
-
let userId: string | undefined
|
|
1831
|
-
if (userIdStr) {
|
|
1832
|
-
try {
|
|
1833
|
-
userId = userIdStr
|
|
1834
|
-
} catch (e) {
|
|
1835
|
-
console.warn('[SDK]: Failed to parse userId from localStorage:', e)
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
// Check if this will be a handoff flow (desktop device)
|
|
1840
|
-
const isHandoff = this.isDesktopDevice()
|
|
1841
|
-
|
|
1842
|
-
// For mobile devices (non-handoff), if no key exists, trigger enrollment
|
|
1843
|
-
if (!isHandoff && !keyId) {
|
|
1844
|
-
console.log('[SDK]: No key found in localStorage, triggering enrollment...')
|
|
1845
|
-
|
|
1846
|
-
// Probe iframe first to check if key exists there
|
|
1847
|
-
keyId = await this.probeIframeForKey()
|
|
1848
|
-
|
|
1849
|
-
if (!keyId) {
|
|
1850
|
-
// No key found, trigger enrollment
|
|
1851
|
-
console.log('[SDK]: No key in iframe either, opening enrollment popup...')
|
|
1852
|
-
keyId = await this.enrollWithPopup()
|
|
1853
|
-
|
|
1854
|
-
if (!keyId) {
|
|
1855
|
-
throw new Error('Enrollment completed but no key found. Please try again.')
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
console.log('[SDK]: Enrollment successful, obtained keyId:', keyId)
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
// Create signature document BEFORE signing (this also validates domain and template and starts countdown for handoff)
|
|
1863
|
-
const createResponse = await fetch(`${this.apiUrl}/api/signatures/create`, {
|
|
1864
|
-
method: 'POST',
|
|
1865
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1866
|
-
body: JSON.stringify({
|
|
1867
|
-
appId: this.authAppId,
|
|
1868
|
-
baseUrl,
|
|
1869
|
-
endpoint: uriPath,
|
|
1870
|
-
method: apiCallStructure.method,
|
|
1871
|
-
keyId: keyId || '',
|
|
1872
|
-
userId: userId,
|
|
1873
|
-
apiCallStructure,
|
|
1874
|
-
signatureId,
|
|
1875
|
-
isHandoff // Flag to indicate handoff flow
|
|
1876
|
-
})
|
|
1877
|
-
})
|
|
1878
|
-
|
|
1879
|
-
if (createResponse.ok) {
|
|
1880
|
-
const createData = await createResponse.json()
|
|
1881
|
-
if (createData.success && createData.data?.signatureId) {
|
|
1882
|
-
console.log('[SDK]: Signature document created with signatureId:', createData.data.signatureId)
|
|
1883
|
-
} else {
|
|
1884
|
-
throw new Error('Failed to create signature document: ' + (createData.error || 'No signatureId returned'))
|
|
1885
|
-
}
|
|
1886
|
-
} else {
|
|
1887
|
-
const errorData = await createResponse.json().catch(() => ({}))
|
|
1888
|
-
throw new Error('Failed to create signature: ' + (errorData.error || 'Creation request failed'))
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
// Now sign the payload (pass signatureId for desktop handoff, and keyId if we have it)
|
|
1892
|
-
const signingResult = await this.signPayload(apiCallStructure, signatureId, keyId || undefined)
|
|
1893
|
-
|
|
1894
|
-
// Make the actual API call with signature and signatureId in header
|
|
1895
|
-
// Use keyId from signingResult (Device B's keyId for handoff, or localStorage keyId for mobile)
|
|
1896
|
-
const signingResultKeyId = signingResult.keyId || ''
|
|
1897
|
-
console.log('[SDK]: 🔍 KeyId debug - signingResult.keyId:', signingResultKeyId, 'localStorage keyId:', keyId, 'keyId type:', typeof signingResultKeyId, 'empty?:', signingResultKeyId === '')
|
|
1898
|
-
const keyIdForAuth = signingResultKeyId || keyId || ''
|
|
1899
|
-
console.log('[SDK]: ✅ Final keyId for auth header:', keyIdForAuth, '(from', signingResultKeyId && signingResultKeyId !== '' ? 'Device B' : 'localStorage fallback', ')')
|
|
1900
|
-
const authHeaderValue = btoa(JSON.stringify({ signature: signingResult.signature, keyId: keyIdForAuth, signatureId }))
|
|
1901
|
-
|
|
1902
|
-
const response = await fetch(url, {
|
|
1903
|
-
...options,
|
|
1904
|
-
headers: {
|
|
1905
|
-
...options.headers,
|
|
1906
|
-
'x-truly-auth': authHeaderValue
|
|
1907
|
-
}
|
|
1908
|
-
})
|
|
1909
|
-
|
|
1910
|
-
console.log('[SDK]: Request complete, status:', response.status)
|
|
1911
|
-
|
|
1912
|
-
return {
|
|
1913
|
-
response,
|
|
1914
|
-
signature: signingResult.signature,
|
|
1915
|
-
signatureId
|
|
1916
|
-
}
|
|
1917
|
-
} catch (error: any) {
|
|
1918
|
-
console.error('[SDK]: Signature creation error:', error)
|
|
1919
|
-
// Silently fail for countdown expiry - don't throw error
|
|
1920
|
-
if (error.message && error.message.includes('Countdown expired')) {
|
|
1921
|
-
// Return empty result to indicate silent failure
|
|
1922
|
-
return { response: null as any, signature: '', signatureId: signatureId || '' }
|
|
1923
|
-
}
|
|
1924
|
-
throw new Error('Signature creation failed: ' + (error.message || 'Unknown error'))
|
|
1925
|
-
}
|
|
1926
|
-
} else {
|
|
1927
|
-
throw new Error('authAppId is required for signature validation. Please configure authAppId in SDK initialization.')
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
} catch (error: any) {
|
|
1931
|
-
console.error('[SDK]: fetchWithSignature error:', error)
|
|
1932
|
-
// Silently fail for countdown expiry - just reset state
|
|
1933
|
-
if (error.message && error.message.includes('Countdown expired')) {
|
|
1934
|
-
// Don't throw error, just silently reset - return empty result
|
|
1935
|
-
// signatureId is in scope from fetchWithSignature function
|
|
1936
|
-
const expiredSignatureId = signatureId || ''
|
|
1937
|
-
return { response: null as any, signature: '', signatureId: expiredSignatureId }
|
|
1938
|
-
}
|
|
1939
|
-
throw error
|
|
1940
|
-
}
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
|
-
/**
|
|
1944
|
-
* Public method to probe for keyId - checks localStorage first, then probes TrulyYou frontend via iframe
|
|
1945
|
-
* Returns the keyId if found, null otherwise
|
|
1946
|
-
* @param handoff - If true, probes for handoff keyId (with _handoff suffix)
|
|
1947
|
-
*/
|
|
1948
|
-
async probeForKeyId(handoff: boolean = false): Promise<string | null> {
|
|
1949
|
-
// Ensure authFlowId is loaded first
|
|
1950
|
-
await this.ensureAuthFlowIdLoaded()
|
|
1951
|
-
|
|
1952
|
-
if (!this.brandingCache?.authFlowId) {
|
|
1953
|
-
console.warn('[SDK-PROBE]: authFlowId is required but not available')
|
|
1954
|
-
return null
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
const authFlowId = this.brandingCache.authFlowId
|
|
1958
|
-
|
|
1959
|
-
// First, check localStorage (same origin - this app's localStorage)
|
|
1960
|
-
const storageKey = handoff ? `trulyYouKeyId_${authFlowId}_handoff` : `trulyYouKeyId_${authFlowId}`
|
|
1961
|
-
const keyIdFromStorage = typeof window !== 'undefined' ? localStorage.getItem(storageKey) : null
|
|
1962
|
-
|
|
1963
|
-
if (keyIdFromStorage) {
|
|
1964
|
-
console.log(`[SDK-PROBE]: Found ${handoff ? 'handoff ' : ''}keyId in same-origin localStorage`)
|
|
1965
|
-
return keyIdFromStorage
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
// If not found in same-origin localStorage, probe TrulyYou frontend's localStorage via iframe
|
|
1969
|
-
console.log(`[SDK-PROBE]: ${handoff ? 'Handoff ' : ''}KeyId not found in same-origin localStorage, probing TrulyYou frontend...`)
|
|
1970
|
-
return await this.probeIframeForKey(handoff)
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
/**
|
|
1974
|
-
* Clear all keys (both handoff and non-handoff) from localStorage
|
|
1975
|
-
* Clears from both same-origin localStorage and TrulyYou frontend's localStorage via iframe
|
|
1976
|
-
*/
|
|
1977
|
-
async clearAllKeys(): Promise<void> {
|
|
1978
|
-
// Ensure authFlowId is loaded first
|
|
1979
|
-
await this.ensureAuthFlowIdLoaded()
|
|
1980
|
-
|
|
1981
|
-
if (!this.brandingCache?.authFlowId) {
|
|
1982
|
-
console.warn('[SDK-CLEAR]: authFlowId is required but not available')
|
|
1983
|
-
return
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
const authFlowId = this.brandingCache.authFlowId
|
|
1987
|
-
|
|
1988
|
-
// Clear from same-origin localStorage
|
|
1989
|
-
if (typeof window !== 'undefined') {
|
|
1990
|
-
const regularKey = `trulyYouKeyId_${authFlowId}`
|
|
1991
|
-
const handoffKey = `trulyYouKeyId_${authFlowId}_handoff`
|
|
1992
|
-
|
|
1993
|
-
localStorage.removeItem(regularKey)
|
|
1994
|
-
localStorage.removeItem(handoffKey)
|
|
1995
|
-
|
|
1996
|
-
console.log('[SDK-CLEAR]: ✅ Cleared keys from same-origin localStorage:', regularKey, handoffKey)
|
|
1997
|
-
}
|
|
1998
|
-
|
|
1999
|
-
// Also clear from TrulyYou frontend's localStorage via iframe
|
|
2000
|
-
try {
|
|
2001
|
-
const iframe = document.createElement('iframe')
|
|
2002
|
-
iframe.style.cssText = 'position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;'
|
|
2003
|
-
|
|
2004
|
-
const clearUrl = new URL(`${this.frontendUrl}/clear-keys.html`)
|
|
2005
|
-
clearUrl.searchParams.set('authFlowId', authFlowId)
|
|
2006
|
-
|
|
2007
|
-
iframe.src = clearUrl.toString()
|
|
2008
|
-
document.body.appendChild(iframe)
|
|
2009
|
-
|
|
2010
|
-
// Clean up after a delay
|
|
2011
|
-
setTimeout(() => {
|
|
2012
|
-
if (iframe.parentNode) {
|
|
2013
|
-
iframe.parentNode.removeChild(iframe)
|
|
2014
|
-
}
|
|
2015
|
-
}, 2000)
|
|
2016
|
-
|
|
2017
|
-
console.log('[SDK-CLEAR]: ✅ Cleared keys from TrulyYou frontend localStorage via iframe')
|
|
2018
|
-
} catch (error) {
|
|
2019
|
-
console.warn('[SDK-CLEAR]: Failed to clear keys from TrulyYou frontend localStorage:', error)
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
|