@embarkai/ui-kit 0.1.1

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.
@@ -0,0 +1,436 @@
1
+ /**
2
+ * X (Twitter) OAuth Popup Script
3
+ * Handles OAuth 2.0 with PKCE flow for X authentication
4
+ */
5
+
6
+ // Get configuration from URL parameters
7
+ const urlParams = new URLSearchParams(window.location.search);
8
+
9
+ // TSS_URL and PROJECT_ID are build-time constants, passed via URL on initial load
10
+ // After backend redirect, we use stored values from localStorage
11
+ const TSS_URL = urlParams.get('tssUrl') || localStorage.getItem('x_oauth_tssUrl') || (typeof __LUMIA_TSS_URL__ !== 'undefined' ? __LUMIA_TSS_URL__ : null);
12
+ const MODE = urlParams.get('mode') || localStorage.getItem('x_oauth_mode') || 'login';
13
+ const PROJECT_ID = urlParams.get('projectId') || localStorage.getItem('x_oauth_projectId') || (typeof window !== 'undefined' && window.__LUMIA_PROJECT_ID__) || null;
14
+
15
+ const STATE = urlParams.get('state');
16
+ const CODE = urlParams.get('code');
17
+ const ERROR_PARAM = urlParams.get('error');
18
+ const SUCCESS = urlParams.get('success'); // Backend redirected with success=true
19
+
20
+ console.log('[X OAuth] Initializing with:', { TSS_URL, MODE, PROJECT_ID, STATE, CODE, ERROR_PARAM, SUCCESS });
21
+
22
+ const contentEl = document.getElementById('content');
23
+
24
+ // Track if we've successfully sent auth result to parent
25
+ let authResultSent = false;
26
+ // Track if we're redirecting to X OAuth (to prevent cancellation on redirect)
27
+ let isRedirectingToProvider = false;
28
+
29
+ function showLoading(message) {
30
+ if (contentEl) {
31
+ contentEl.innerHTML = `
32
+ <div class="loading">
33
+ <div class="spinner"></div>
34
+ <p>${message}</p>
35
+ </div>
36
+ `;
37
+ }
38
+ }
39
+
40
+ function showError(message, allowRetry = true) {
41
+ console.error('[X OAuth] Error:', message);
42
+
43
+ if (contentEl) {
44
+ contentEl.innerHTML = `
45
+ <div class="error">
46
+ <strong>Error:</strong> ${message}
47
+ </div>
48
+ ${allowRetry ? '<button class="retry-button" onclick="window.location.reload()">Retry</button>' : ''}
49
+ `;
50
+ }
51
+
52
+ // Send error to opener
53
+ if (window.opener) {
54
+ window.opener.postMessage({
55
+ type: 'X_AUTH_ERROR',
56
+ provider: 'x',
57
+ error: message
58
+ }, '*');
59
+ }
60
+ }
61
+
62
+ function showSuccess(message) {
63
+ if (contentEl) {
64
+ contentEl.innerHTML = `
65
+ <div class="success">
66
+ ✓ ${message}<br>
67
+ <small style="color: #666;">Closing window...</small>
68
+ </div>
69
+ `;
70
+ }
71
+ }
72
+
73
+ async function startOAuthFlow() {
74
+ if (!TSS_URL) {
75
+ showError('TSS URL not configured. Missing tssUrl parameter.', false);
76
+ return;
77
+ }
78
+
79
+ try {
80
+ showLoading('Starting X OAuth flow...');
81
+
82
+ // Determine the correct endpoint based on mode
83
+ const endpoint = MODE === 'link'
84
+ ? `${TSS_URL}/api/auth/link/x/start`
85
+ : `${TSS_URL}/api/auth/x/start`;
86
+
87
+ // Add projectId to endpoint if available
88
+ const fullEndpoint = PROJECT_ID
89
+ ? `${endpoint}?projectId=${encodeURIComponent(PROJECT_ID)}`
90
+ : endpoint;
91
+
92
+ console.log('[X OAuth] Starting flow with config:', { TSS_URL, MODE, PROJECT_ID, endpoint: fullEndpoint });
93
+ console.log('[X OAuth] Making request to:', fullEndpoint);
94
+
95
+ // Call backend to get authorization URL
96
+ const response = await fetch(fullEndpoint, {
97
+ method: 'POST',
98
+ headers: {
99
+ 'Content-Type': 'application/json',
100
+ ...(MODE === 'link' ? {
101
+ 'Authorization': `Bearer ${urlParams.get('token')}`
102
+ } : {})
103
+ },
104
+ credentials: 'include'
105
+ });
106
+
107
+ if (!response.ok) {
108
+ const errorText = await response.text().catch(() => '');
109
+ let errorData;
110
+ try {
111
+ errorData = JSON.parse(errorText);
112
+ } catch {
113
+ errorData = { message: errorText };
114
+ }
115
+ console.error('[X OAuth] Backend error response:', { status: response.status, errorData, errorText });
116
+ throw new Error(errorData.message || errorText || `Failed to start OAuth: ${response.statusText}`);
117
+ }
118
+
119
+ const data = await response.json();
120
+ console.log('[X OAuth] Backend response:', data);
121
+
122
+ // Backend may return 'url' or 'authorizationUrl'
123
+ const authUrl = data.authorizationUrl || data.url;
124
+ console.log('[X OAuth] Authorization URL:', authUrl);
125
+
126
+ if (!authUrl) {
127
+ throw new Error('No authorization URL returned from server');
128
+ }
129
+
130
+ // Store state and config in localStorage (persists across redirects)
131
+ if (data.state) {
132
+ localStorage.setItem('x_oauth_state', data.state);
133
+ }
134
+ localStorage.setItem('x_oauth_mode', MODE);
135
+ localStorage.setItem('x_oauth_tssUrl', TSS_URL);
136
+ if (PROJECT_ID) {
137
+ localStorage.setItem('x_oauth_projectId', PROJECT_ID);
138
+ }
139
+
140
+ // Redirect to X OAuth page
141
+ showLoading('Redirecting to X...');
142
+ isRedirectingToProvider = true; // Prevent cancellation message on redirect
143
+ window.location.href = authUrl;
144
+
145
+ } catch (error) {
146
+ console.error('[X OAuth] Start flow error:', error);
147
+ const errorMessage = error.message || 'Failed to start OAuth flow';
148
+ console.error('[X OAuth] Error details:', { error, MODE, TSS_URL, PROJECT_ID });
149
+ showError(errorMessage);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Handle successful OAuth after backend redirect
155
+ * Backend processes callback and redirects back with success=true
156
+ * If authCode is present, exchanges it for tokens (Safari ITP fix)
157
+ * Otherwise falls back to cookie-based verify (desktop browsers)
158
+ */
159
+ async function handleBackendSuccess() {
160
+ try {
161
+ showLoading('Completing authentication...');
162
+
163
+ console.log('[X OAuth] Backend redirected with success, verifying session...');
164
+ console.log('[X OAuth] Using config:', { TSS_URL, PROJECT_ID, MODE });
165
+
166
+ // Validate required parameters
167
+ if (!TSS_URL) {
168
+ throw new Error('Missing TSS URL. Check build-time configuration.');
169
+ }
170
+
171
+ // Check for auth code (new flow for Safari ITP compatibility)
172
+ const authCode = urlParams.get('authCode');
173
+ let userData;
174
+ let tokens = null;
175
+
176
+ if (authCode) {
177
+ // New flow: Exchange auth code for tokens (fixes Safari ITP issues)
178
+ console.log('[X OAuth] Auth code present, exchanging for tokens...');
179
+
180
+ const exchangeEndpoint = PROJECT_ID
181
+ ? `${TSS_URL}/api/auth/exchange-code?projectId=${encodeURIComponent(PROJECT_ID)}`
182
+ : `${TSS_URL}/api/auth/exchange-code`;
183
+
184
+ const exchangeResponse = await fetch(exchangeEndpoint, {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({ code: authCode })
188
+ });
189
+
190
+ if (!exchangeResponse.ok) {
191
+ const errorData = await exchangeResponse.json().catch(() => ({}));
192
+ console.error('[X OAuth] Code exchange failed:', exchangeResponse.status, errorData);
193
+ throw new Error(errorData.error || 'Failed to exchange auth code');
194
+ }
195
+
196
+ const exchangeData = await exchangeResponse.json();
197
+ console.log('[X OAuth] Code exchange successful:', { userId: exchangeData.userId, hasKeyshare: exchangeData.hasKeyshare });
198
+
199
+ // Extract tokens for postMessage
200
+ tokens = {
201
+ accessToken: exchangeData.accessToken,
202
+ refreshToken: exchangeData.refreshToken
203
+ };
204
+
205
+ // Build userData in the same format as verify endpoint
206
+ userData = {
207
+ valid: true,
208
+ userId: exchangeData.userId,
209
+ sessionId: exchangeData.sessionId,
210
+ expiresIn: exchangeData.expiresIn,
211
+ hasKeyshare: exchangeData.hasKeyshare,
212
+ displayName: exchangeData.displayName,
213
+ providers: exchangeData.providers
214
+ };
215
+ } else {
216
+ // Legacy flow: Use cookies (works on desktop browsers)
217
+ console.log('[X OAuth] No auth code, falling back to cookie-based verify...');
218
+
219
+ const verifyEndpoint = PROJECT_ID
220
+ ? `${TSS_URL}/api/auth/verify?projectId=${encodeURIComponent(PROJECT_ID)}`
221
+ : `${TSS_URL}/api/auth/verify`;
222
+
223
+ const verifyResponse = await fetch(verifyEndpoint, {
224
+ method: 'GET',
225
+ credentials: 'include',
226
+ });
227
+
228
+ if (!verifyResponse.ok) {
229
+ console.error('[X OAuth] Verify failed:', verifyResponse.status);
230
+ throw new Error('Failed to verify authentication. Session may not be created.');
231
+ }
232
+
233
+ userData = await verifyResponse.json();
234
+ }
235
+
236
+ console.log('[X OAuth] Authentication verified:', userData);
237
+
238
+ // Send success to opener
239
+ if (window.opener) {
240
+ const message = {
241
+ type: 'X_AUTH_SUCCESS',
242
+ provider: 'x',
243
+ user: userData,
244
+ mode: MODE
245
+ };
246
+
247
+ // Include tokens if we have them (new flow)
248
+ if (tokens) {
249
+ message.tokens = tokens;
250
+ }
251
+
252
+ window.opener.postMessage(message, '*');
253
+
254
+ // Mark that we've sent the auth result
255
+ authResultSent = true;
256
+
257
+ showSuccess(MODE === 'link' ? 'Account linked successfully!' : 'Authentication successful!');
258
+
259
+ // Clean up localStorage
260
+ localStorage.removeItem('x_oauth_state');
261
+ localStorage.removeItem('x_oauth_mode');
262
+ localStorage.removeItem('x_oauth_tssUrl');
263
+ localStorage.removeItem('x_oauth_projectId');
264
+
265
+ setTimeout(() => {
266
+ window.close();
267
+ }, 1500);
268
+ } else {
269
+ console.error('[X OAuth] No opener window found');
270
+ showError('No opener window found. Please close this window manually.');
271
+ }
272
+
273
+ } catch (error) {
274
+ console.error('[X OAuth] Backend success handler error:', error);
275
+ showError(error.message || 'Failed to complete authentication');
276
+
277
+ // Clean up localStorage on error
278
+ localStorage.removeItem('x_oauth_state');
279
+ localStorage.removeItem('x_oauth_mode');
280
+ localStorage.removeItem('x_oauth_tssUrl');
281
+ localStorage.removeItem('x_oauth_projectId');
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Handle OAuth callback (legacy, may not be used if backend handles redirect_uri)
287
+ */
288
+ async function handleCallback() {
289
+ try {
290
+ showLoading('Completing authentication...');
291
+
292
+ // Verify state matches
293
+ const savedState = localStorage.getItem('x_oauth_state');
294
+ const savedMode = localStorage.getItem('x_oauth_mode');
295
+
296
+ if (savedState && STATE !== savedState) {
297
+ throw new Error('Invalid state parameter. Possible CSRF attack.');
298
+ }
299
+
300
+ if (ERROR_PARAM === 'access_denied') {
301
+ console.warn('[X OAuth] User denied authorization during legacy callback');
302
+ redirectWithAccessDenied();
303
+ return;
304
+ }
305
+
306
+ if (ERROR_PARAM) {
307
+ throw new Error(`OAuth error: ${ERROR_PARAM}`);
308
+ }
309
+
310
+ if (!CODE) {
311
+ throw new Error('No authorization code received');
312
+ }
313
+
314
+ console.log('[X OAuth] Callback successful, mode:', savedMode);
315
+
316
+ // For login mode, the backend callback endpoint handles everything
317
+ // and redirects back with tokens. For link mode, we need to verify.
318
+ if (savedMode === 'login') {
319
+ // The callback endpoint should have set cookies/tokens
320
+ // Get user info to verify
321
+ const response = await fetch(`${TSS_URL}/api/auth/verify`, {
322
+ credentials: 'include'
323
+ });
324
+
325
+ if (!response.ok) {
326
+ throw new Error('Failed to verify authentication');
327
+ }
328
+
329
+ const userData = await response.json();
330
+ console.log('[X OAuth] User data:', userData);
331
+
332
+ // Send success to opener
333
+ if (window.opener) {
334
+ window.opener.postMessage({
335
+ type: 'X_AUTH_SUCCESS',
336
+ provider: 'x',
337
+ user: userData,
338
+ mode: 'login'
339
+ }, '*');
340
+
341
+ showSuccess('Authentication successful!');
342
+
343
+ setTimeout(() => {
344
+ window.close();
345
+ }, 1500);
346
+ } else {
347
+ showError('No opener window found');
348
+ }
349
+ } else {
350
+ // Link mode - backend has already linked the account
351
+ if (window.opener) {
352
+ window.opener.postMessage({
353
+ type: 'X_AUTH_SUCCESS',
354
+ provider: 'x',
355
+ mode: 'link'
356
+ }, '*');
357
+
358
+ showSuccess('Account linked successfully!');
359
+
360
+ setTimeout(() => {
361
+ window.close();
362
+ }, 1500);
363
+ } else {
364
+ showError('No opener window found');
365
+ }
366
+ }
367
+
368
+ } catch (error) {
369
+ console.error('[X OAuth] Callback error:', error);
370
+ showError(error.message || 'Failed to complete authentication');
371
+ }
372
+ }
373
+
374
+ // Determine if this is a callback or initial load
375
+ if (ERROR_PARAM) {
376
+ // Backend redirected with error
377
+ const errorMessage = decodeURIComponent(ERROR_PARAM);
378
+ console.error('[X OAuth] Backend returned error:', errorMessage);
379
+ showError(errorMessage);
380
+
381
+ // Send error to opener
382
+ if (window.opener) {
383
+ window.opener.postMessage({
384
+ type: 'X_AUTH_ERROR',
385
+ provider: 'x',
386
+ error: errorMessage
387
+ }, '*');
388
+ authResultSent = true; // Mark that we've sent result
389
+ }
390
+ } else if (SUCCESS === 'true') {
391
+ // Backend redirected back after successful OAuth
392
+ handleBackendSuccess();
393
+ } else if (CODE) {
394
+ // This is a callback from X (should not happen with backend redirect_uri)
395
+ console.warn('[X OAuth] Received code parameter, but backend should handle this');
396
+ handleCallback();
397
+ } else {
398
+ // This is initial load, start OAuth flow
399
+ if (document.readyState === 'loading') {
400
+ document.addEventListener('DOMContentLoaded', startOAuthFlow);
401
+ } else {
402
+ startOAuthFlow();
403
+ }
404
+ }
405
+
406
+ // Handle popup closing without authentication
407
+ window.addEventListener('beforeunload', () => {
408
+ // Only send cancellation if we haven't sent success/error AND not redirecting to provider
409
+ if (!authResultSent && !isRedirectingToProvider && window.opener) {
410
+ console.log('[X OAuth] Window closing without auth result, sending cancellation');
411
+ window.opener.postMessage({
412
+ type: 'X_AUTH_CANCELLED',
413
+ provider: 'x'
414
+ }, '*');
415
+ }
416
+ });
417
+ function redirectWithAccessDenied() {
418
+ const storedState = localStorage.getItem('x_oauth_state');
419
+ const storedMode = localStorage.getItem('x_oauth_mode') || MODE || 'login';
420
+
421
+ localStorage.removeItem('x_oauth_state');
422
+ localStorage.removeItem('x_oauth_mode');
423
+ localStorage.removeItem('x_oauth_tssUrl');
424
+ localStorage.removeItem('x_oauth_projectId');
425
+
426
+ const params = new URLSearchParams();
427
+ params.set('success', 'false');
428
+ params.set('error', 'ACCESS_DENIED');
429
+ params.set('mode', storedMode);
430
+ if (storedState) {
431
+ params.set('state', storedState);
432
+ }
433
+
434
+ const redirectUrl = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
435
+ window.location.replace(redirectUrl);
436
+ }