@nextsparkjs/plugin-social-media-publisher 0.1.0-beta.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.
- package/.env.example +76 -0
- package/README.md +423 -0
- package/api/social/connect/callback/route.ts +669 -0
- package/api/social/connect/route.ts +327 -0
- package/api/social/disconnect/route.ts +187 -0
- package/api/social/publish/route.ts +402 -0
- package/docs/01-getting-started/01-introduction.md +471 -0
- package/docs/01-getting-started/02-installation.md +471 -0
- package/docs/01-getting-started/03-configuration.md +515 -0
- package/docs/02-core-features/01-oauth-integration.md +501 -0
- package/docs/02-core-features/02-publishing.md +527 -0
- package/docs/02-core-features/03-token-management.md +661 -0
- package/docs/02-core-features/04-audit-logging.md +646 -0
- package/docs/03-advanced-usage/01-provider-apis.md +764 -0
- package/docs/03-advanced-usage/02-custom-integrations.md +695 -0
- package/docs/03-advanced-usage/03-per-client-architecture.md +575 -0
- package/docs/04-use-cases/01-agency-management.md +661 -0
- package/docs/04-use-cases/02-content-publishing.md +668 -0
- package/docs/04-use-cases/03-analytics-reporting.md +748 -0
- package/entities/audit-logs/audit-logs.config.ts +150 -0
- package/lib/oauth-helper.ts +167 -0
- package/lib/providers/facebook.ts +672 -0
- package/lib/providers/index.ts +21 -0
- package/lib/providers/instagram.ts +791 -0
- package/lib/validation.ts +155 -0
- package/migrations/001_social_media_tables.sql +167 -0
- package/package.json +15 -0
- package/plugin.config.ts +81 -0
- package/tsconfig.json +47 -0
- package/types/social.types.ts +171 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Callback Handler for Social Media Publishing
|
|
3
|
+
*
|
|
4
|
+
* GET endpoint that receives the OAuth redirect from Facebook
|
|
5
|
+
* Accessible via: /api/v1/plugin/social-media-publisher/social/connect/callback
|
|
6
|
+
*
|
|
7
|
+
* Query params:
|
|
8
|
+
* - code: Authorization code from Facebook
|
|
9
|
+
* - state: CSRF protection token with clientId embedded (format: {randomState}&platform={platform}&clientId={clientId})
|
|
10
|
+
* - error: (optional) Error if user denied permission
|
|
11
|
+
* - error_description: (optional) Error description
|
|
12
|
+
*
|
|
13
|
+
* Architecture:
|
|
14
|
+
* - Uses child entity API (/api/v1/clients/{clientId}/social-platforms) instead of direct DB inserts
|
|
15
|
+
* - Social accounts belong to clients, not users
|
|
16
|
+
* - Redirects to /clients/{clientId}/social-platforms on success/error
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
20
|
+
import { authenticateRequest } from '@nextsparkjs/core/lib/api/auth/dual-auth'
|
|
21
|
+
import { TokenEncryption } from '@nextsparkjs/core/lib/oauth/encryption'
|
|
22
|
+
import { FacebookAPI } from '../../../../lib/providers/facebook'
|
|
23
|
+
import {
|
|
24
|
+
exchangeCodeForToken,
|
|
25
|
+
getOAuthConfig
|
|
26
|
+
} from '../../../../lib/oauth-helper'
|
|
27
|
+
import { mutateWithRLS } from '@nextsparkjs/core/lib/db'
|
|
28
|
+
|
|
29
|
+
export async function GET(request: NextRequest) {
|
|
30
|
+
try {
|
|
31
|
+
// 1. Parse state to extract clientId, platform, and mode
|
|
32
|
+
const { searchParams } = new URL(request.url)
|
|
33
|
+
const state = searchParams.get('state') || ''
|
|
34
|
+
|
|
35
|
+
// State format: "{randomState}&platform={platform}&clientId={clientId}&mode={mode}"
|
|
36
|
+
const stateParams = new URLSearchParams(state)
|
|
37
|
+
const clientId = stateParams.get('clientId')
|
|
38
|
+
const platform = stateParams.get('platform') || 'instagram_business'
|
|
39
|
+
const mode = stateParams.get('mode') // 'preview' = return data without saving, undefined = save to DB (default)
|
|
40
|
+
|
|
41
|
+
console.log('[oauth-callback] Received OAuth callback:', {
|
|
42
|
+
platform,
|
|
43
|
+
clientId,
|
|
44
|
+
mode: mode || 'save (default)',
|
|
45
|
+
hasCode: !!searchParams.get('code')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// 2. Check for OAuth errors (user denied, etc.)
|
|
49
|
+
const error = searchParams.get('error')
|
|
50
|
+
const errorDescription = searchParams.get('error_description')
|
|
51
|
+
|
|
52
|
+
if (error) {
|
|
53
|
+
console.error('[oauth-callback] OAuth error:', error, errorDescription)
|
|
54
|
+
|
|
55
|
+
// Map Meta OAuth errors to user-friendly error types
|
|
56
|
+
let errorType = error
|
|
57
|
+
let userMessage = errorDescription || 'Authentication failed'
|
|
58
|
+
|
|
59
|
+
if (error === 'access_denied') {
|
|
60
|
+
errorType = 'user_cancelled'
|
|
61
|
+
userMessage = 'You cancelled the authorization process'
|
|
62
|
+
} else if (error === 'unauthorized_client') {
|
|
63
|
+
errorType = 'app_not_authorized'
|
|
64
|
+
userMessage = 'This app is not authorized to access your Facebook account'
|
|
65
|
+
} else if (error === 'server_error' || error === 'temporarily_unavailable') {
|
|
66
|
+
errorType = 'meta_server_error'
|
|
67
|
+
userMessage = 'Facebook is temporarily unavailable. Please try again later'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Return HTML page that sends error postMessage to opener window
|
|
71
|
+
const html = `
|
|
72
|
+
<!DOCTYPE html>
|
|
73
|
+
<html>
|
|
74
|
+
<head>
|
|
75
|
+
<title>OAuth Error</title>
|
|
76
|
+
<style>
|
|
77
|
+
body {
|
|
78
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: center;
|
|
82
|
+
height: 100vh;
|
|
83
|
+
margin: 0;
|
|
84
|
+
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
85
|
+
color: white;
|
|
86
|
+
}
|
|
87
|
+
.container {
|
|
88
|
+
text-align: center;
|
|
89
|
+
padding: 2rem;
|
|
90
|
+
}
|
|
91
|
+
.error-icon {
|
|
92
|
+
font-size: 4rem;
|
|
93
|
+
margin-bottom: 1rem;
|
|
94
|
+
}
|
|
95
|
+
h1 {
|
|
96
|
+
font-size: 1.5rem;
|
|
97
|
+
margin-bottom: 0.5rem;
|
|
98
|
+
}
|
|
99
|
+
p {
|
|
100
|
+
opacity: 0.9;
|
|
101
|
+
}
|
|
102
|
+
</style>
|
|
103
|
+
</head>
|
|
104
|
+
<body>
|
|
105
|
+
<div class="container">
|
|
106
|
+
<div class="error-icon">❌</div>
|
|
107
|
+
<h1>Authentication Failed</h1>
|
|
108
|
+
<p>${userMessage.replace(/'/g, "\\'")}. This window will close automatically...</p>
|
|
109
|
+
</div>
|
|
110
|
+
<script>
|
|
111
|
+
// Send error message to parent window
|
|
112
|
+
if (window.opener) {
|
|
113
|
+
window.opener.postMessage({
|
|
114
|
+
type: 'oauth-error',
|
|
115
|
+
error: '${errorType}',
|
|
116
|
+
errorDescription: '${userMessage.replace(/'/g, "\\'")}'
|
|
117
|
+
}, window.location.origin);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Close this popup after 3 seconds
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
window.close();
|
|
123
|
+
}, 3000);
|
|
124
|
+
</script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>
|
|
127
|
+
`
|
|
128
|
+
|
|
129
|
+
return new NextResponse(html, {
|
|
130
|
+
headers: {
|
|
131
|
+
'Content-Type': 'text/html',
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 3. Validate required parameters
|
|
137
|
+
if (!clientId) {
|
|
138
|
+
return NextResponse.redirect(
|
|
139
|
+
new URL(
|
|
140
|
+
`/dashboard?error=missing_client&message=Client ID not provided in OAuth flow`,
|
|
141
|
+
request.url
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const code = searchParams.get('code')
|
|
147
|
+
if (!code) {
|
|
148
|
+
return NextResponse.redirect(
|
|
149
|
+
new URL(
|
|
150
|
+
`/clients/${clientId}/social-platforms?error=missing_code&message=Authorization code not provided`,
|
|
151
|
+
request.url
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 4. Authentication (user must be logged in)
|
|
157
|
+
const authResult = await authenticateRequest(request)
|
|
158
|
+
if (!authResult.success) {
|
|
159
|
+
return NextResponse.redirect(
|
|
160
|
+
new URL(
|
|
161
|
+
`/auth/login?error=authentication_required&message=You must be logged in to connect accounts&redirect=/clients/${clientId}/social-platforms`,
|
|
162
|
+
request.url
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 5. Verify user owns the client
|
|
168
|
+
const clientCheckResult = await mutateWithRLS<{ id: string }>(
|
|
169
|
+
`SELECT id FROM "clients" WHERE id = $1 AND "userId" = $2`,
|
|
170
|
+
[clientId, authResult.user!.id],
|
|
171
|
+
authResult.user!.id
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if (clientCheckResult.rows.length === 0) {
|
|
175
|
+
return NextResponse.redirect(
|
|
176
|
+
new URL(
|
|
177
|
+
`/clients/${clientId}/social-platforms?error=unauthorized&message=You do not have access to this client`,
|
|
178
|
+
request.url
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 6. State validation (CSRF protection)
|
|
184
|
+
// TODO: Implement proper state validation with session storage
|
|
185
|
+
// For now, we'll log it and accept any state with a warning
|
|
186
|
+
if (state) {
|
|
187
|
+
console.log('[oauth-callback] Received state:', state)
|
|
188
|
+
console.warn('[oauth-callback] ⚠️ State validation not yet implemented - potential CSRF vulnerability')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 5. Exchange authorization code for access token
|
|
192
|
+
const oauthConfig = getOAuthConfig()
|
|
193
|
+
const tokenData = await exchangeCodeForToken(
|
|
194
|
+
code,
|
|
195
|
+
oauthConfig,
|
|
196
|
+
platform as 'facebook_page' | 'instagram_business'
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const userAccessToken = tokenData.accessToken
|
|
200
|
+
let expiresIn = tokenData.expiresIn
|
|
201
|
+
|
|
202
|
+
// 6. Get accounts based on platform
|
|
203
|
+
let accountsToConnect: Array<{
|
|
204
|
+
platformAccountId: string
|
|
205
|
+
username: string
|
|
206
|
+
accessToken: string
|
|
207
|
+
permissions: string[]
|
|
208
|
+
metadata: any
|
|
209
|
+
}> = []
|
|
210
|
+
|
|
211
|
+
if (platform === 'facebook_page') {
|
|
212
|
+
// Get Facebook Pages
|
|
213
|
+
const pages = await FacebookAPI.getUserPages(userAccessToken)
|
|
214
|
+
|
|
215
|
+
accountsToConnect = pages.map(page => ({
|
|
216
|
+
platformAccountId: page.id,
|
|
217
|
+
username: page.name,
|
|
218
|
+
accessToken: page.accessToken, // Use page token, not user token
|
|
219
|
+
permissions: page.tasks || [],
|
|
220
|
+
metadata: {
|
|
221
|
+
category: page.category,
|
|
222
|
+
pictureUrl: page.pictureUrl,
|
|
223
|
+
},
|
|
224
|
+
}))
|
|
225
|
+
} else if (platform === 'instagram_business') {
|
|
226
|
+
// Instagram Graph API (via Facebook Pages)
|
|
227
|
+
console.log('[oauth-callback] Using Instagram Graph API (via Facebook Pages)')
|
|
228
|
+
|
|
229
|
+
// Step 1: Get user's Facebook Pages
|
|
230
|
+
console.log('[oauth-callback] Fetching Facebook Pages...')
|
|
231
|
+
const pages = await FacebookAPI.getUserPages(userAccessToken)
|
|
232
|
+
console.log(`[oauth-callback] Found ${pages.length} Facebook Pages`)
|
|
233
|
+
|
|
234
|
+
// Step 2: For each Page, check if it has Instagram Business Account
|
|
235
|
+
for (const page of pages) {
|
|
236
|
+
console.log(`[oauth-callback] Checking Page "${page.name}" for Instagram...`)
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const igAccount = await FacebookAPI.getInstagramBusinessAccount(
|
|
240
|
+
page.id,
|
|
241
|
+
page.accessToken // Use Page token, not user token
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if (igAccount) {
|
|
245
|
+
console.log(`[oauth-callback] ✅ Found Instagram @${igAccount.username} linked to Page "${page.name}"`)
|
|
246
|
+
|
|
247
|
+
accountsToConnect.push({
|
|
248
|
+
platformAccountId: igAccount.id,
|
|
249
|
+
username: igAccount.username,
|
|
250
|
+
accessToken: page.accessToken, // Use Page token for Instagram Graph API calls
|
|
251
|
+
permissions: [
|
|
252
|
+
'instagram_basic',
|
|
253
|
+
'instagram_content_publish',
|
|
254
|
+
'instagram_manage_comments',
|
|
255
|
+
'pages_show_list',
|
|
256
|
+
'pages_read_engagement',
|
|
257
|
+
],
|
|
258
|
+
metadata: {
|
|
259
|
+
// Instagram Graph API data (read-only, refreshable)
|
|
260
|
+
username: igAccount.username,
|
|
261
|
+
name: igAccount.name,
|
|
262
|
+
profilePictureUrl: igAccount.profilePictureUrl,
|
|
263
|
+
followersCount: igAccount.followersCount,
|
|
264
|
+
followsCount: igAccount.followsCount,
|
|
265
|
+
mediaCount: igAccount.mediaCount,
|
|
266
|
+
biography: igAccount.biography,
|
|
267
|
+
website: igAccount.website,
|
|
268
|
+
lastSyncedAt: new Date().toISOString(),
|
|
269
|
+
|
|
270
|
+
// Facebook Page info (for reference)
|
|
271
|
+
facebookPageId: page.id,
|
|
272
|
+
facebookPageName: page.name,
|
|
273
|
+
facebookPageCategory: page.category,
|
|
274
|
+
|
|
275
|
+
// User-editable fields (pre-filled, modifiable in form)
|
|
276
|
+
displayName: igAccount.name || igAccount.username, // Pre-fill with name or username
|
|
277
|
+
description: igAccount.biography || '', // Pre-fill with bio if available
|
|
278
|
+
tags: [], // Empty, user fills in form
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
} else {
|
|
282
|
+
console.log(`[oauth-callback] Page "${page.name}" has no Instagram Business Account linked`)
|
|
283
|
+
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
console.error(`[oauth-callback] Error checking Instagram for Page "${page.name}":`, error)
|
|
286
|
+
// Continue with next page instead of failing entire flow
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// In preview mode, having 0 Instagram accounts is OK - user can still see their Pages
|
|
291
|
+
// In save mode, having 0 accounts is an error (nothing to save)
|
|
292
|
+
if (accountsToConnect.length === 0 && mode !== 'preview') {
|
|
293
|
+
console.warn('[oauth-callback] ⚠️ No Instagram Business Accounts found across all Facebook Pages (save mode)')
|
|
294
|
+
|
|
295
|
+
// Return HTML that sends a specific error message to parent window
|
|
296
|
+
const html = `
|
|
297
|
+
<!DOCTYPE html>
|
|
298
|
+
<html>
|
|
299
|
+
<head>
|
|
300
|
+
<title>No Instagram Accounts Found</title>
|
|
301
|
+
<style>
|
|
302
|
+
body {
|
|
303
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
304
|
+
display: flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
justify-content: center;
|
|
307
|
+
height: 100vh;
|
|
308
|
+
margin: 0;
|
|
309
|
+
background: linear-gradient(135deg, #ffa726 0%, #fb8c00 100%);
|
|
310
|
+
color: white;
|
|
311
|
+
}
|
|
312
|
+
.container {
|
|
313
|
+
text-align: center;
|
|
314
|
+
padding: 2rem;
|
|
315
|
+
max-width: 500px;
|
|
316
|
+
}
|
|
317
|
+
.warning-icon {
|
|
318
|
+
font-size: 4rem;
|
|
319
|
+
margin-bottom: 1rem;
|
|
320
|
+
}
|
|
321
|
+
h1 {
|
|
322
|
+
font-size: 1.5rem;
|
|
323
|
+
margin-bottom: 0.5rem;
|
|
324
|
+
}
|
|
325
|
+
p {
|
|
326
|
+
opacity: 0.9;
|
|
327
|
+
line-height: 1.5;
|
|
328
|
+
}
|
|
329
|
+
</style>
|
|
330
|
+
</head>
|
|
331
|
+
<body>
|
|
332
|
+
<div class="container">
|
|
333
|
+
<div class="warning-icon">⚠️</div>
|
|
334
|
+
<h1>No Instagram Accounts Found</h1>
|
|
335
|
+
<p>Although you authorized the app, we couldn't find any Instagram Business Accounts linked to your Facebook Pages.</p>
|
|
336
|
+
<p style="margin-top: 1rem; font-size: 0.9rem;">This window will close automatically...</p>
|
|
337
|
+
</div>
|
|
338
|
+
<script>
|
|
339
|
+
// Send specific error to parent window
|
|
340
|
+
if (window.opener) {
|
|
341
|
+
window.opener.postMessage({
|
|
342
|
+
type: 'oauth-error',
|
|
343
|
+
error: 'no_instagram_accounts',
|
|
344
|
+
errorDescription: 'No Instagram Business Accounts found linked to your Facebook Pages. Please link your Instagram account to a Facebook Page first.'
|
|
345
|
+
}, window.location.origin);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Close this popup after 3 seconds
|
|
349
|
+
setTimeout(() => {
|
|
350
|
+
window.close();
|
|
351
|
+
}, 3000);
|
|
352
|
+
</script>
|
|
353
|
+
</body>
|
|
354
|
+
</html>
|
|
355
|
+
`
|
|
356
|
+
|
|
357
|
+
return new NextResponse(html, {
|
|
358
|
+
headers: {
|
|
359
|
+
'Content-Type': 'text/html',
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
} else if (accountsToConnect.length === 0 && mode === 'preview') {
|
|
363
|
+
console.log('[oauth-callback] No Instagram accounts found, but in preview mode - continuing with empty array')
|
|
364
|
+
} else {
|
|
365
|
+
console.log(`[oauth-callback] ✅ Found ${accountsToConnect.length} Instagram Business Account(s)`)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 7. Handle preview mode vs save mode
|
|
370
|
+
if (mode === 'preview') {
|
|
371
|
+
// PREVIEW MODE: Return data without saving to DB
|
|
372
|
+
// This allows the form to pre-fill with OAuth data before user clicks Save
|
|
373
|
+
console.log('[oauth-callback] Preview mode detected - returning data without saving to DB')
|
|
374
|
+
|
|
375
|
+
const previewData = accountsToConnect.map(account => ({
|
|
376
|
+
platform,
|
|
377
|
+
platformAccountId: account.platformAccountId,
|
|
378
|
+
username: account.username,
|
|
379
|
+
accessToken: account.accessToken, // Return unencrypted token (will be encrypted on save)
|
|
380
|
+
tokenExpiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
|
|
381
|
+
permissions: account.permissions,
|
|
382
|
+
accountMetadata: account.metadata,
|
|
383
|
+
}))
|
|
384
|
+
|
|
385
|
+
const html = `
|
|
386
|
+
<!DOCTYPE html>
|
|
387
|
+
<html>
|
|
388
|
+
<head>
|
|
389
|
+
<title>OAuth Preview</title>
|
|
390
|
+
<style>
|
|
391
|
+
body {
|
|
392
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
393
|
+
display: flex;
|
|
394
|
+
align-items: center;
|
|
395
|
+
justify-content: center;
|
|
396
|
+
height: 100vh;
|
|
397
|
+
margin: 0;
|
|
398
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
399
|
+
color: white;
|
|
400
|
+
}
|
|
401
|
+
.container {
|
|
402
|
+
text-align: center;
|
|
403
|
+
padding: 2rem;
|
|
404
|
+
}
|
|
405
|
+
.loading-icon {
|
|
406
|
+
font-size: 4rem;
|
|
407
|
+
margin-bottom: 1rem;
|
|
408
|
+
animation: spin 2s linear infinite;
|
|
409
|
+
}
|
|
410
|
+
@keyframes spin {
|
|
411
|
+
0% { transform: rotate(0deg); }
|
|
412
|
+
100% { transform: rotate(360deg); }
|
|
413
|
+
}
|
|
414
|
+
h1 {
|
|
415
|
+
font-size: 1.5rem;
|
|
416
|
+
margin-bottom: 0.5rem;
|
|
417
|
+
}
|
|
418
|
+
p {
|
|
419
|
+
opacity: 0.9;
|
|
420
|
+
}
|
|
421
|
+
</style>
|
|
422
|
+
</head>
|
|
423
|
+
<body>
|
|
424
|
+
<div class="container">
|
|
425
|
+
<div class="loading-icon">🔄</div>
|
|
426
|
+
<h1>Loading Profile Data...</h1>
|
|
427
|
+
<p>Pre-filling your ${platform} information...</p>
|
|
428
|
+
</div>
|
|
429
|
+
<script>
|
|
430
|
+
// Send preview data to parent window
|
|
431
|
+
if (window.opener) {
|
|
432
|
+
window.opener.postMessage({
|
|
433
|
+
type: 'oauth-preview',
|
|
434
|
+
platform: '${platform}',
|
|
435
|
+
accounts: ${JSON.stringify(previewData)}
|
|
436
|
+
}, window.location.origin);
|
|
437
|
+
|
|
438
|
+
// Close this popup after sending data
|
|
439
|
+
setTimeout(() => {
|
|
440
|
+
window.close();
|
|
441
|
+
}, 1000);
|
|
442
|
+
}
|
|
443
|
+
</script>
|
|
444
|
+
</body>
|
|
445
|
+
</html>
|
|
446
|
+
`
|
|
447
|
+
|
|
448
|
+
return new NextResponse(html, {
|
|
449
|
+
status: 200,
|
|
450
|
+
headers: { 'Content-Type': 'text/html' }
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// SAVE MODE (default): Encrypt tokens and save accounts to child entity
|
|
455
|
+
const savedCount = accountsToConnect.length
|
|
456
|
+
|
|
457
|
+
for (const account of accountsToConnect) {
|
|
458
|
+
// Encrypt access token
|
|
459
|
+
const encryptedToken = await TokenEncryption.encrypt(account.accessToken)
|
|
460
|
+
|
|
461
|
+
// Calculate expiration date
|
|
462
|
+
const now = new Date()
|
|
463
|
+
const expiresAt = new Date(now.getTime() + expiresIn * 1000)
|
|
464
|
+
|
|
465
|
+
// Insert social account into clients_social_platforms table (child entity)
|
|
466
|
+
const result = await mutateWithRLS<{
|
|
467
|
+
id: string
|
|
468
|
+
platform: string
|
|
469
|
+
username: string
|
|
470
|
+
}>(
|
|
471
|
+
`INSERT INTO "clients_social_platforms"
|
|
472
|
+
("parentId", platform, "platformAccountId", "username", "accessToken",
|
|
473
|
+
"tokenExpiresAt", permissions, "accountMetadata", "isActive")
|
|
474
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
475
|
+
ON CONFLICT ("parentId", "platformAccountId")
|
|
476
|
+
WHERE "platformAccountId" IS NOT NULL
|
|
477
|
+
DO UPDATE SET
|
|
478
|
+
"accessToken" = EXCLUDED."accessToken",
|
|
479
|
+
"tokenExpiresAt" = EXCLUDED."tokenExpiresAt",
|
|
480
|
+
permissions = EXCLUDED.permissions,
|
|
481
|
+
"accountMetadata" = EXCLUDED."accountMetadata",
|
|
482
|
+
"isActive" = EXCLUDED."isActive",
|
|
483
|
+
"updatedAt" = CURRENT_TIMESTAMP
|
|
484
|
+
RETURNING *`,
|
|
485
|
+
[
|
|
486
|
+
clientId, // parentId (client that owns this account)
|
|
487
|
+
platform,
|
|
488
|
+
account.platformAccountId,
|
|
489
|
+
account.username,
|
|
490
|
+
`${encryptedToken.encrypted}:${encryptedToken.iv}:${encryptedToken.keyId}`,
|
|
491
|
+
expiresAt.toISOString(),
|
|
492
|
+
JSON.stringify(account.permissions),
|
|
493
|
+
JSON.stringify(account.metadata),
|
|
494
|
+
true // isActive
|
|
495
|
+
],
|
|
496
|
+
authResult.user!.id
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
const savedAccount = result.rows[0]!
|
|
500
|
+
console.log('[oauth-callback] ✅ Account saved to child entity:', {
|
|
501
|
+
id: savedAccount.id,
|
|
502
|
+
clientId,
|
|
503
|
+
platform,
|
|
504
|
+
accountName: account.username
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
// Create audit log entry
|
|
508
|
+
await mutateWithRLS(
|
|
509
|
+
`INSERT INTO "audit_logs"
|
|
510
|
+
("userId", "accountId", action, details, "ipAddress", "userAgent")
|
|
511
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
512
|
+
[
|
|
513
|
+
authResult.user!.id,
|
|
514
|
+
savedAccount.id,
|
|
515
|
+
'account_connected',
|
|
516
|
+
JSON.stringify({
|
|
517
|
+
clientId,
|
|
518
|
+
platform,
|
|
519
|
+
accountName: account.username,
|
|
520
|
+
success: true,
|
|
521
|
+
connectedAt: new Date().toISOString()
|
|
522
|
+
}),
|
|
523
|
+
request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null,
|
|
524
|
+
request.headers.get('user-agent') || null
|
|
525
|
+
],
|
|
526
|
+
authResult.user!.id
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
console.log('[oauth-callback] ✅ Audit log created for account connection')
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 8. Return HTML page that sends postMessage to opener window
|
|
533
|
+
// This allows the OAuth popup to communicate back to the parent page
|
|
534
|
+
const html = `
|
|
535
|
+
<!DOCTYPE html>
|
|
536
|
+
<html>
|
|
537
|
+
<head>
|
|
538
|
+
<title>OAuth Success</title>
|
|
539
|
+
<style>
|
|
540
|
+
body {
|
|
541
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
542
|
+
display: flex;
|
|
543
|
+
align-items: center;
|
|
544
|
+
justify-content: center;
|
|
545
|
+
height: 100vh;
|
|
546
|
+
margin: 0;
|
|
547
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
548
|
+
color: white;
|
|
549
|
+
}
|
|
550
|
+
.container {
|
|
551
|
+
text-align: center;
|
|
552
|
+
padding: 2rem;
|
|
553
|
+
}
|
|
554
|
+
.success-icon {
|
|
555
|
+
font-size: 4rem;
|
|
556
|
+
margin-bottom: 1rem;
|
|
557
|
+
}
|
|
558
|
+
h1 {
|
|
559
|
+
font-size: 1.5rem;
|
|
560
|
+
margin-bottom: 0.5rem;
|
|
561
|
+
}
|
|
562
|
+
p {
|
|
563
|
+
opacity: 0.9;
|
|
564
|
+
}
|
|
565
|
+
</style>
|
|
566
|
+
</head>
|
|
567
|
+
<body>
|
|
568
|
+
<div class="container">
|
|
569
|
+
<div class="success-icon">✅</div>
|
|
570
|
+
<h1>Account Connected Successfully!</h1>
|
|
571
|
+
<p>Connected ${savedCount} ${platform} account(s). This window will close automatically...</p>
|
|
572
|
+
</div>
|
|
573
|
+
<script>
|
|
574
|
+
// Send success message to parent window
|
|
575
|
+
if (window.opener) {
|
|
576
|
+
window.opener.postMessage({
|
|
577
|
+
type: 'oauth-success',
|
|
578
|
+
platform: '${platform}',
|
|
579
|
+
connectedCount: ${savedCount}
|
|
580
|
+
}, window.location.origin);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Close this popup after 2 seconds
|
|
584
|
+
setTimeout(() => {
|
|
585
|
+
window.close();
|
|
586
|
+
}, 2000);
|
|
587
|
+
</script>
|
|
588
|
+
</body>
|
|
589
|
+
</html>
|
|
590
|
+
`
|
|
591
|
+
|
|
592
|
+
return new NextResponse(html, {
|
|
593
|
+
headers: {
|
|
594
|
+
'Content-Type': 'text/html',
|
|
595
|
+
},
|
|
596
|
+
})
|
|
597
|
+
} catch (error: unknown) {
|
|
598
|
+
console.error('❌ OAuth callback error:', error)
|
|
599
|
+
|
|
600
|
+
// Extract error message
|
|
601
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
|
602
|
+
|
|
603
|
+
// Return HTML page that sends error postMessage to opener window
|
|
604
|
+
// This is critical - if we redirect, the popup redirects and never communicates with parent
|
|
605
|
+
const html = `
|
|
606
|
+
<!DOCTYPE html>
|
|
607
|
+
<html>
|
|
608
|
+
<head>
|
|
609
|
+
<title>OAuth Error</title>
|
|
610
|
+
<style>
|
|
611
|
+
body {
|
|
612
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
613
|
+
display: flex;
|
|
614
|
+
align-items: center;
|
|
615
|
+
justify-content: center;
|
|
616
|
+
height: 100vh;
|
|
617
|
+
margin: 0;
|
|
618
|
+
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
619
|
+
color: white;
|
|
620
|
+
}
|
|
621
|
+
.container {
|
|
622
|
+
text-align: center;
|
|
623
|
+
padding: 2rem;
|
|
624
|
+
}
|
|
625
|
+
.error-icon {
|
|
626
|
+
font-size: 4rem;
|
|
627
|
+
margin-bottom: 1rem;
|
|
628
|
+
}
|
|
629
|
+
h1 {
|
|
630
|
+
font-size: 1.5rem;
|
|
631
|
+
margin-bottom: 0.5rem;
|
|
632
|
+
}
|
|
633
|
+
p {
|
|
634
|
+
opacity: 0.9;
|
|
635
|
+
}
|
|
636
|
+
</style>
|
|
637
|
+
</head>
|
|
638
|
+
<body>
|
|
639
|
+
<div class="container">
|
|
640
|
+
<div class="error-icon">❌</div>
|
|
641
|
+
<h1>Connection Failed</h1>
|
|
642
|
+
<p>${errorMessage.replace(/'/g, "\\'")}. This window will close automatically...</p>
|
|
643
|
+
</div>
|
|
644
|
+
<script>
|
|
645
|
+
// Send error message to parent window
|
|
646
|
+
if (window.opener) {
|
|
647
|
+
window.opener.postMessage({
|
|
648
|
+
type: 'oauth-error',
|
|
649
|
+
error: 'callback_exception',
|
|
650
|
+
errorDescription: '${errorMessage.replace(/'/g, "\\'")}'
|
|
651
|
+
}, window.location.origin);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Close this popup after 3 seconds
|
|
655
|
+
setTimeout(() => {
|
|
656
|
+
window.close();
|
|
657
|
+
}, 3000);
|
|
658
|
+
</script>
|
|
659
|
+
</body>
|
|
660
|
+
</html>
|
|
661
|
+
`
|
|
662
|
+
|
|
663
|
+
return new NextResponse(html, {
|
|
664
|
+
headers: {
|
|
665
|
+
'Content-Type': 'text/html',
|
|
666
|
+
},
|
|
667
|
+
})
|
|
668
|
+
}
|
|
669
|
+
}
|