@app-connect/core 1.7.18 → 1.7.19
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/connector/proxy/index.js +2 -1
- package/handlers/log.js +181 -10
- package/handlers/plugin.js +27 -0
- package/handlers/user.js +31 -2
- package/index.js +99 -22
- package/lib/authSession.js +21 -12
- package/lib/callLogComposer.js +1 -1
- package/lib/debugTracer.js +20 -2
- package/lib/util.js +21 -4
- package/mcp/README.md +392 -0
- package/mcp/mcpHandler.js +293 -82
- package/mcp/tools/checkAuthStatus.js +27 -34
- package/mcp/tools/createCallLog.js +13 -9
- package/mcp/tools/createContact.js +2 -6
- package/mcp/tools/doAuth.js +27 -157
- package/mcp/tools/findContactByName.js +6 -9
- package/mcp/tools/findContactByPhone.js +2 -6
- package/mcp/tools/getGoogleFilePicker.js +5 -9
- package/mcp/tools/getHelp.js +2 -3
- package/mcp/tools/getPublicConnectors.js +41 -28
- package/mcp/tools/index.js +11 -36
- package/mcp/tools/logout.js +5 -10
- package/mcp/tools/rcGetCallLogs.js +3 -20
- package/mcp/ui/App/App.tsx +361 -0
- package/mcp/ui/App/components/AuthInfoForm.tsx +113 -0
- package/mcp/ui/App/components/AuthSuccess.tsx +22 -0
- package/mcp/ui/App/components/ConnectorList.tsx +82 -0
- package/mcp/ui/App/components/DebugPanel.tsx +43 -0
- package/mcp/ui/App/components/OAuthConnect.tsx +270 -0
- package/mcp/ui/App/lib/callTool.ts +130 -0
- package/mcp/ui/App/lib/debugLog.ts +41 -0
- package/mcp/ui/App/lib/developerPortal.ts +111 -0
- package/mcp/ui/App/main.css +6 -0
- package/mcp/ui/App/root.tsx +13 -0
- package/mcp/ui/dist/index.html +53 -0
- package/mcp/ui/index.html +13 -0
- package/mcp/ui/package-lock.json +6356 -0
- package/mcp/ui/package.json +25 -0
- package/mcp/ui/tsconfig.json +26 -0
- package/mcp/ui/vite.config.ts +16 -0
- package/models/llmSessionModel.js +14 -0
- package/package.json +72 -72
- package/releaseNotes.json +12 -0
- package/test/handlers/plugin.test.js +287 -0
- package/test/lib/util.test.js +379 -1
- package/test/mcp/tools/createCallLog.test.js +3 -3
- package/test/mcp/tools/doAuth.test.js +40 -303
- package/test/mcp/tools/findContactByName.test.js +3 -3
- package/test/mcp/tools/findContactByPhone.test.js +3 -3
- package/test/mcp/tools/getGoogleFilePicker.test.js +7 -7
- package/test/mcp/tools/getPublicConnectors.test.js +49 -70
- package/test/mcp/tools/logout.test.js +2 -2
- package/mcp/SupportedPlatforms.md +0 -12
- package/mcp/tools/collectAuthInfo.js +0 -91
- package/mcp/tools/setConnector.js +0 -69
- package/test/mcp/tools/collectAuthInfo.test.js +0 -234
- package/test/mcp/tools/setConnector.test.js +0 -177
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Button } from '@openai/apps-sdk-ui/components/Button'
|
|
3
|
+
import { callTool, getServerUrl, updateModelContext } from '../lib/callTool'
|
|
4
|
+
import { dbg } from '../lib/debugLog'
|
|
5
|
+
|
|
6
|
+
type AuthStatus = 'ready' | 'creatingSession' | 'polling' | 'error'
|
|
7
|
+
|
|
8
|
+
interface OAuthConnectProps {
|
|
9
|
+
connectorManifest: any
|
|
10
|
+
connectorName: string
|
|
11
|
+
connectorDisplayName: string
|
|
12
|
+
hostname: string
|
|
13
|
+
openaiSessionId?: string | null
|
|
14
|
+
rcExtensionId?: string | null
|
|
15
|
+
onSuccess: (data: { jwtToken: string; userInfo?: any }) => void
|
|
16
|
+
onError: (error: string) => void
|
|
17
|
+
onBack: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compose the OAuth authorization URL entirely client-side.
|
|
22
|
+
* The widget already has the full manifest and serverUrl — no server round-trip needed.
|
|
23
|
+
*/
|
|
24
|
+
function buildAuthUri(platform: any, sessionId: string, hostname: string, serverUrl: string): string {
|
|
25
|
+
const oauth = platform.auth.oauth
|
|
26
|
+
let stateParam = `sessionId=${sessionId}&platform=${platform.name}&hostname=${hostname}`
|
|
27
|
+
if (oauth.customState) stateParam += `&${oauth.customState}`
|
|
28
|
+
|
|
29
|
+
return `${oauth.authUrl}?` +
|
|
30
|
+
`response_type=code` +
|
|
31
|
+
`&client_id=${oauth.clientId}` +
|
|
32
|
+
`${oauth.scope ? `&${oauth.scope}` : ''}` +
|
|
33
|
+
`&state=${encodeURIComponent(stateParam)}` +
|
|
34
|
+
`&redirect_uri=${serverUrl}/oauth-callback`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
38
|
+
const MAX_POLL_ATTEMPTS = 100; // ~5 minutes
|
|
39
|
+
|
|
40
|
+
export function OAuthConnect({
|
|
41
|
+
connectorManifest,
|
|
42
|
+
connectorName,
|
|
43
|
+
connectorDisplayName,
|
|
44
|
+
hostname,
|
|
45
|
+
openaiSessionId,
|
|
46
|
+
rcExtensionId,
|
|
47
|
+
onSuccess,
|
|
48
|
+
onError,
|
|
49
|
+
onBack,
|
|
50
|
+
}: OAuthConnectProps) {
|
|
51
|
+
const [status, setStatus] = useState<AuthStatus>('ready')
|
|
52
|
+
const [authUri, setAuthUri] = useState<string | null>(null)
|
|
53
|
+
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
|
54
|
+
const [sessionReady, setSessionReady] = useState(false)
|
|
55
|
+
|
|
56
|
+
const sessionIdRef = useRef<string | null>(null)
|
|
57
|
+
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
58
|
+
const pollCountRef = useRef(0)
|
|
59
|
+
const unmountedRef = useRef(false)
|
|
60
|
+
|
|
61
|
+
// Cleanup on unmount
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
return () => {
|
|
64
|
+
unmountedRef.current = true
|
|
65
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
|
|
66
|
+
}
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
// Build authUri client-side immediately — no server round-trip needed.
|
|
70
|
+
// Simultaneously call doAuth in the background to create the DB session.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
let cancelled = false
|
|
73
|
+
const platform = connectorManifest?.platforms?.[connectorName]
|
|
74
|
+
|
|
75
|
+
if (!platform) {
|
|
76
|
+
setStatus('error')
|
|
77
|
+
setErrorMsg('Invalid connector configuration')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Use openaiSessionId as the primary session key so the OAuth callback
|
|
82
|
+
// can be correlated with the ChatGPT conversation. Fall back to a random
|
|
83
|
+
// UUID when the widget is loaded outside ChatGPT.
|
|
84
|
+
const sessionId = openaiSessionId ?? crypto.randomUUID()
|
|
85
|
+
sessionIdRef.current = sessionId
|
|
86
|
+
const serverUrl = getServerUrl() ?? ''
|
|
87
|
+
const uri = buildAuthUri(platform, sessionId, hostname, serverUrl)
|
|
88
|
+
setAuthUri(uri)
|
|
89
|
+
dbg.info('authUri composed client-side, sessionId:', sessionId)
|
|
90
|
+
|
|
91
|
+
// Create the server-side session in background (fast DB write only)
|
|
92
|
+
callTool('doAuth', { connectorName, hostname, sessionId }).then((result) => {
|
|
93
|
+
if (cancelled || unmountedRef.current) return
|
|
94
|
+
if (!result?.success) {
|
|
95
|
+
dbg.warn('doAuth session creation failed:', result?.error)
|
|
96
|
+
setStatus('error')
|
|
97
|
+
setErrorMsg(result?.error || 'Failed to prepare authentication session')
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
dbg.info('doAuth session created successfully')
|
|
101
|
+
setSessionReady(true)
|
|
102
|
+
}).catch((err: any) => {
|
|
103
|
+
if (cancelled || unmountedRef.current) return
|
|
104
|
+
dbg.error('doAuth error:', err.message)
|
|
105
|
+
setStatus('error')
|
|
106
|
+
setErrorMsg(err.message || 'Failed to prepare authentication session')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return () => { cancelled = true }
|
|
110
|
+
}, [connectorManifest, connectorName, hostname])
|
|
111
|
+
|
|
112
|
+
const startPolling = () => {
|
|
113
|
+
setStatus('polling')
|
|
114
|
+
pollCountRef.current = 0
|
|
115
|
+
|
|
116
|
+
pollTimerRef.current = setInterval(async () => {
|
|
117
|
+
if (unmountedRef.current) {
|
|
118
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
pollCountRef.current += 1
|
|
123
|
+
if (pollCountRef.current > MAX_POLL_ATTEMPTS) {
|
|
124
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
|
|
125
|
+
setStatus('error')
|
|
126
|
+
setErrorMsg('Authorization timed out. Please try again.')
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const res = await callTool('checkAuthStatus', {
|
|
132
|
+
sessionId: sessionIdRef.current,
|
|
133
|
+
...(rcExtensionId ? { rcExtensionId } : {}),
|
|
134
|
+
})
|
|
135
|
+
const sc = res?.data ?? null
|
|
136
|
+
|
|
137
|
+
if (unmountedRef.current) return
|
|
138
|
+
|
|
139
|
+
const dataStatus = sc?.status
|
|
140
|
+
if (dataStatus === 'completed' && sc?.jwtToken) {
|
|
141
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
|
|
142
|
+
|
|
143
|
+
// Push the JWT into ChatGPT's model context so it can be used
|
|
144
|
+
// in future tool calls. updateModelContext tries ui/update-model-context
|
|
145
|
+
// first (invisible to user, visible to model), then falls back to ui/message.
|
|
146
|
+
const userLabel = sc.userInfo?.name ? ` as ${sc.userInfo.name}` : ''
|
|
147
|
+
await updateModelContext(
|
|
148
|
+
`CRM authentication with ${connectorDisplayName} completed${userLabel}. ` +
|
|
149
|
+
`Platform: ${connectorName}. ` +
|
|
150
|
+
`jwtToken: ${sc.jwtToken}. ` +
|
|
151
|
+
`IMPORTANT: Store this jwtToken and pass it as a parameter to all future CRM tool calls.`
|
|
152
|
+
)
|
|
153
|
+
dbg.info('updateModelContext sent with jwtToken')
|
|
154
|
+
|
|
155
|
+
onSuccess({
|
|
156
|
+
jwtToken: sc.jwtToken,
|
|
157
|
+
userInfo: sc.userInfo,
|
|
158
|
+
})
|
|
159
|
+
} else if (dataStatus === 'failed') {
|
|
160
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
|
|
161
|
+
setStatus('error')
|
|
162
|
+
setErrorMsg(sc?.errorMessage || 'Authorization failed')
|
|
163
|
+
} else if (dataStatus === 'expired') {
|
|
164
|
+
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
|
|
165
|
+
setStatus('error')
|
|
166
|
+
setErrorMsg('Authorization session expired. Please try again.')
|
|
167
|
+
}
|
|
168
|
+
// 'pending' -> keep polling
|
|
169
|
+
} catch {
|
|
170
|
+
// Transient errors during polling are acceptable; keep trying
|
|
171
|
+
}
|
|
172
|
+
}, POLL_INTERVAL_MS)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const handleAuthorizeClick = () => {
|
|
176
|
+
if (authUri && sessionReady) {
|
|
177
|
+
window.open(authUri, '_blank', 'noopener,noreferrer')
|
|
178
|
+
startPolling()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handleRetry = () => {
|
|
183
|
+
setStatus('error')
|
|
184
|
+
setErrorMsg(null)
|
|
185
|
+
onError('retry')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Error state
|
|
189
|
+
if (status === 'error') {
|
|
190
|
+
return (
|
|
191
|
+
<div className="w-full max-w-md">
|
|
192
|
+
<div className="mb-4">
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={onBack}
|
|
196
|
+
className="text-sm text-secondary hover:text-primary transition-colors mb-2 cursor-pointer"
|
|
197
|
+
>
|
|
198
|
+
← Back
|
|
199
|
+
</button>
|
|
200
|
+
<h2 className="heading-lg">Authentication Error</h2>
|
|
201
|
+
</div>
|
|
202
|
+
<div className="rounded-lg border border-red-200 bg-red-50 p-4 mb-4">
|
|
203
|
+
<p className="text-red-700 text-sm">{errorMsg}</p>
|
|
204
|
+
</div>
|
|
205
|
+
<Button color="primary" size="sm" onClick={handleRetry}>
|
|
206
|
+
Try Again
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Ready to authorize or polling
|
|
213
|
+
return (
|
|
214
|
+
<div className="w-full max-w-md">
|
|
215
|
+
<div className="mb-4">
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={onBack}
|
|
219
|
+
className="text-sm text-secondary hover:text-primary transition-colors mb-2 cursor-pointer"
|
|
220
|
+
>
|
|
221
|
+
← Back
|
|
222
|
+
</button>
|
|
223
|
+
<h2 className="heading-lg">Authorize {connectorDisplayName}</h2>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div className="rounded-xl border border-default bg-surface p-4 space-y-4">
|
|
227
|
+
{status === 'ready' && (
|
|
228
|
+
<>
|
|
229
|
+
<p className="text-sm text-secondary">
|
|
230
|
+
Click the button below to open {connectorDisplayName}'s authorization
|
|
231
|
+
page. After you approve access, return here to continue.
|
|
232
|
+
</p>
|
|
233
|
+
<Button
|
|
234
|
+
color="primary"
|
|
235
|
+
size="md"
|
|
236
|
+
onClick={handleAuthorizeClick}
|
|
237
|
+
disabled={!sessionReady}
|
|
238
|
+
>
|
|
239
|
+
{sessionReady
|
|
240
|
+
? `Authorize in ${connectorDisplayName}`
|
|
241
|
+
: 'Preparing…'}
|
|
242
|
+
</Button>
|
|
243
|
+
</>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{status === 'polling' && (
|
|
247
|
+
<div className="text-center space-y-3">
|
|
248
|
+
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
|
|
249
|
+
<p className="text-sm text-secondary">
|
|
250
|
+
Waiting for you to authorize in {connectorDisplayName}...
|
|
251
|
+
</p>
|
|
252
|
+
<p className="text-xs text-secondary opacity-60">
|
|
253
|
+
Complete the authorization in the opened tab, then come back here.
|
|
254
|
+
</p>
|
|
255
|
+
{authUri && (
|
|
256
|
+
<a
|
|
257
|
+
href={authUri}
|
|
258
|
+
target="_blank"
|
|
259
|
+
rel="noopener noreferrer"
|
|
260
|
+
className="text-xs text-primary hover:underline"
|
|
261
|
+
>
|
|
262
|
+
Open authorization page again
|
|
263
|
+
</a>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { dbg } from './debugLog';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server URL for direct HTTP tool calls.
|
|
5
|
+
* Set from structuredContent.serverUrl when the initial tool output arrives.
|
|
6
|
+
*/
|
|
7
|
+
let _serverUrl: string | null = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Send a JSON-RPC 2.0 request to the host (ChatGPT) and await the response.
|
|
11
|
+
* Used for MCP Apps bridge calls that require a round-trip (e.g. ui/update-model-context).
|
|
12
|
+
*/
|
|
13
|
+
function rpcRequest(method: string, params: unknown, timeoutMs = 10_000): Promise<unknown> {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const id = `${method}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
16
|
+
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
window.removeEventListener('message', onMessage)
|
|
19
|
+
reject(new Error(`rpcRequest "${method}" timed out after ${timeoutMs}ms`))
|
|
20
|
+
}, timeoutMs)
|
|
21
|
+
|
|
22
|
+
function onMessage(event: MessageEvent) {
|
|
23
|
+
if (event.source !== window.parent) return
|
|
24
|
+
const msg = event.data
|
|
25
|
+
if (!msg || msg.jsonrpc !== '2.0' || msg.id !== id) return
|
|
26
|
+
clearTimeout(timer)
|
|
27
|
+
window.removeEventListener('message', onMessage)
|
|
28
|
+
if (msg.error) reject(new Error(msg.error.message ?? JSON.stringify(msg.error)))
|
|
29
|
+
else resolve(msg.result)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
window.addEventListener('message', onMessage, { passive: true })
|
|
33
|
+
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*')
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Inject text into the model's context without showing it as a visible chat message.
|
|
39
|
+
* Uses the MCP Apps bridge ui/update-model-context (preferred), with ui/message as fallback.
|
|
40
|
+
*/
|
|
41
|
+
export async function updateModelContext(text: string): Promise<void> {
|
|
42
|
+
const content = [{ type: 'text', text }]
|
|
43
|
+
|
|
44
|
+
// Primary: MCP Apps standard — invisible to user, visible to model
|
|
45
|
+
try {
|
|
46
|
+
await rpcRequest('ui/update-model-context', { content })
|
|
47
|
+
dbg.info('updateModelContext: ui/update-model-context succeeded')
|
|
48
|
+
return
|
|
49
|
+
} catch (err: any) {
|
|
50
|
+
dbg.warn('updateModelContext: ui/update-model-context failed, trying ui/message:', err.message)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fallback: visible user message (ui/message notification — fire and forget)
|
|
54
|
+
try {
|
|
55
|
+
window.parent.postMessage(
|
|
56
|
+
{ jsonrpc: '2.0', method: 'ui/message', params: { role: 'user', content } },
|
|
57
|
+
'*',
|
|
58
|
+
)
|
|
59
|
+
dbg.info('updateModelContext: ui/message sent')
|
|
60
|
+
return
|
|
61
|
+
} catch (err: any) {
|
|
62
|
+
dbg.warn('updateModelContext: ui/message failed, trying window.openai.sendMessage:', err.message)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Last resort: legacy ChatGPT Apps SDK
|
|
66
|
+
const openai = (window as any).openai
|
|
67
|
+
if (typeof openai?.sendMessage === 'function') {
|
|
68
|
+
openai.sendMessage(text)
|
|
69
|
+
dbg.info('updateModelContext: window.openai.sendMessage used')
|
|
70
|
+
} else {
|
|
71
|
+
dbg.error('updateModelContext: no mechanism available to update model context')
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function setServerUrl(url: string) {
|
|
76
|
+
_serverUrl = url.replace(/\/+$/, '');
|
|
77
|
+
dbg.info('serverUrl set to:', _serverUrl);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getServerUrl(): string | null {
|
|
81
|
+
return _serverUrl;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Call a widget-accessible tool on the server via direct fetch to /mcp/widget-tool-call.
|
|
86
|
+
*
|
|
87
|
+
* NOTE: window.openai.callTool() is intentionally NOT used here.
|
|
88
|
+
* It routes through ChatGPT's LLM which drops widget-provided args when the MCP
|
|
89
|
+
* tool has no Zod inputSchema registered — args arrive as undefined on the server.
|
|
90
|
+
* Direct fetch correctly forwards all args.
|
|
91
|
+
*/
|
|
92
|
+
export async function callTool(
|
|
93
|
+
toolName: string,
|
|
94
|
+
args: Record<string, unknown> = {},
|
|
95
|
+
): Promise<any> {
|
|
96
|
+
dbg.info(`callTool("${toolName}", ${JSON.stringify(args)})`);
|
|
97
|
+
dbg.info('_serverUrl:', _serverUrl);
|
|
98
|
+
|
|
99
|
+
if (!_serverUrl) {
|
|
100
|
+
const msg = 'serverUrl not set — ensure APP_SERVER env var is set and server restarted';
|
|
101
|
+
dbg.error(msg);
|
|
102
|
+
throw new Error(msg);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const endpoint = `${_serverUrl}/mcp/widget-tool-call`;
|
|
106
|
+
const body = JSON.stringify({ tool: toolName, toolArgs: args });
|
|
107
|
+
dbg.info('fetch POST', endpoint, 'body:', body);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(endpoint, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const text = await res.text();
|
|
117
|
+
dbg.info('fetch response:', res.status, text.slice(0, 300));
|
|
118
|
+
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
let parsed: any = null;
|
|
121
|
+
try { parsed = JSON.parse(text); } catch { /* ignore */ }
|
|
122
|
+
throw new Error(parsed?.error || `HTTP ${res.status}: ${text}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return JSON.parse(text);
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
dbg.error('fetch threw:', err.message);
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface DebugEntry {
|
|
2
|
+
time: string
|
|
3
|
+
level: 'info' | 'warn' | 'error'
|
|
4
|
+
msg: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const MAX_ENTRIES = 50;
|
|
8
|
+
const entries: DebugEntry[] = [];
|
|
9
|
+
const listeners = new Set<() => void>();
|
|
10
|
+
|
|
11
|
+
function ts() {
|
|
12
|
+
return new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function push(level: DebugEntry['level'], parts: unknown[]) {
|
|
16
|
+
const msg = parts
|
|
17
|
+
.map((p) => (typeof p === 'object' ? JSON.stringify(p) : String(p)))
|
|
18
|
+
.join(' ');
|
|
19
|
+
entries.push({ time: ts(), level, msg });
|
|
20
|
+
if (entries.length > MAX_ENTRIES) entries.shift();
|
|
21
|
+
// Mirror to real console
|
|
22
|
+
if (level === 'error') console.error('[debug]', msg);
|
|
23
|
+
else if (level === 'warn') console.warn('[debug]', msg);
|
|
24
|
+
else console.log('[debug]', msg);
|
|
25
|
+
listeners.forEach((fn) => fn());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const dbg = {
|
|
29
|
+
info: (...args: unknown[]) => push('info', args),
|
|
30
|
+
warn: (...args: unknown[]) => push('warn', args),
|
|
31
|
+
error: (...args: unknown[]) => push('error', args),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function getEntries(): DebugEntry[] {
|
|
35
|
+
return [...entries];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function subscribe(fn: () => void): () => void {
|
|
39
|
+
listeners.add(fn);
|
|
40
|
+
return () => listeners.delete(fn);
|
|
41
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { dbg } from './debugLog'
|
|
2
|
+
|
|
3
|
+
const PORTAL_BASE = 'https://appconnect.labs.ringcentral.com/public-api'
|
|
4
|
+
|
|
5
|
+
export const SUPPORTED_PLATFORMS = ['clio']
|
|
6
|
+
|
|
7
|
+
export interface Connector {
|
|
8
|
+
id: string
|
|
9
|
+
name: string
|
|
10
|
+
displayName: string
|
|
11
|
+
description?: string
|
|
12
|
+
status?: 'public' | 'private'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch the list of available connectors directly from the developer portal.
|
|
17
|
+
* Merges public connectors with private ones (if rcAccountId is provided),
|
|
18
|
+
* then filters to SUPPORTED_PLATFORMS.
|
|
19
|
+
*/
|
|
20
|
+
export async function fetchConnectors(rcAccountId?: string | null): Promise<Connector[]> {
|
|
21
|
+
dbg.info('fetchConnectors: rcAccountId=', rcAccountId ?? '(none)')
|
|
22
|
+
|
|
23
|
+
const results: Connector[] = []
|
|
24
|
+
|
|
25
|
+
// Public connectors
|
|
26
|
+
const publicUrl = `${PORTAL_BASE}/connectors`
|
|
27
|
+
dbg.info('fetchConnectors: GET', publicUrl)
|
|
28
|
+
try {
|
|
29
|
+
const resp = await fetch(publicUrl)
|
|
30
|
+
dbg.info('fetchConnectors: public response status=', resp.status, 'ok=', resp.ok)
|
|
31
|
+
if (!resp.ok) {
|
|
32
|
+
const body = await resp.text().catch(() => '(unreadable)')
|
|
33
|
+
throw new Error(`HTTP ${resp.status}: ${body.slice(0, 200)}`)
|
|
34
|
+
}
|
|
35
|
+
const data = await resp.json()
|
|
36
|
+
dbg.info('fetchConnectors: public raw keys=', Object.keys(data).join(', '))
|
|
37
|
+
const list: any[] = data?.connectors ?? data ?? []
|
|
38
|
+
results.push(...list)
|
|
39
|
+
dbg.info('fetchConnectors: public count=', list.length)
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
dbg.error('fetchConnectors: failed to fetch public connectors:', err.message, '| name=', err.name, '| url=', publicUrl)
|
|
42
|
+
throw new Error(`Failed to load connectors: ${err.message}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Private connectors (only if account ID is available)
|
|
46
|
+
if (rcAccountId) {
|
|
47
|
+
const privateUrl = `${PORTAL_BASE}/connectors/internal?accountId=${encodeURIComponent(rcAccountId)}`
|
|
48
|
+
dbg.info('fetchConnectors: GET', privateUrl)
|
|
49
|
+
try {
|
|
50
|
+
const resp = await fetch(privateUrl)
|
|
51
|
+
dbg.info('fetchConnectors: private response status=', resp.status, 'ok=', resp.ok)
|
|
52
|
+
if (resp.ok) {
|
|
53
|
+
const data = await resp.json()
|
|
54
|
+
dbg.info('fetchConnectors: private raw keys=', Object.keys(data).join(', '))
|
|
55
|
+
const list: any[] = data?.privateConnectors ?? data ?? []
|
|
56
|
+
results.push(...list)
|
|
57
|
+
dbg.info('fetchConnectors: private count=', list.length)
|
|
58
|
+
} else {
|
|
59
|
+
const body = await resp.text().catch(() => '(unreadable)')
|
|
60
|
+
dbg.warn('fetchConnectors: private non-ok response:', resp.status, body.slice(0, 200))
|
|
61
|
+
}
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
dbg.warn('fetchConnectors: failed to fetch private connectors (non-fatal):', err.message, '| name=', err.name)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Filter to supported platforms and normalise shape
|
|
68
|
+
const supported = results
|
|
69
|
+
.filter((c) => SUPPORTED_PLATFORMS.includes(c.name))
|
|
70
|
+
.map((c): Connector => ({
|
|
71
|
+
id: c.id,
|
|
72
|
+
name: c.name,
|
|
73
|
+
displayName: c.displayName,
|
|
74
|
+
description: c.description || `Connect to ${c.displayName}`,
|
|
75
|
+
status: (c.status as 'public' | 'private') ?? 'public',
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
dbg.info('fetchConnectors: supported=', supported.map((c) => c.name).join(', '))
|
|
79
|
+
return supported
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fetch the manifest for a specific connector directly from the developer portal.
|
|
84
|
+
*/
|
|
85
|
+
export async function fetchManifest(
|
|
86
|
+
connectorId: string,
|
|
87
|
+
isPrivate: boolean,
|
|
88
|
+
rcAccountId?: string | null,
|
|
89
|
+
): Promise<any> {
|
|
90
|
+
dbg.info('fetchManifest: connectorId=', connectorId, 'isPrivate=', isPrivate)
|
|
91
|
+
|
|
92
|
+
const url = isPrivate && rcAccountId
|
|
93
|
+
? `${PORTAL_BASE}/connectors/${connectorId}/manifest?type=internal&accountId=${encodeURIComponent(rcAccountId)}`
|
|
94
|
+
: `${PORTAL_BASE}/connectors/${connectorId}/manifest`
|
|
95
|
+
|
|
96
|
+
dbg.info('fetchManifest: GET', url)
|
|
97
|
+
try {
|
|
98
|
+
const resp = await fetch(url)
|
|
99
|
+
dbg.info('fetchManifest: response status=', resp.status, 'ok=', resp.ok)
|
|
100
|
+
if (!resp.ok) {
|
|
101
|
+
const body = await resp.text().catch(() => '(unreadable)')
|
|
102
|
+
throw new Error(`HTTP ${resp.status}: ${body.slice(0, 200)}`)
|
|
103
|
+
}
|
|
104
|
+
const manifest = await resp.json()
|
|
105
|
+
dbg.info('fetchManifest: loaded platforms=', Object.keys(manifest?.platforms ?? {}).join(', '))
|
|
106
|
+
return manifest
|
|
107
|
+
} catch (err: any) {
|
|
108
|
+
dbg.error('fetchManifest: failed:', err.message, '| name=', err.name, '| url=', url)
|
|
109
|
+
throw err
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Must be imported first to ensure Tailwind layers and style foundations are defined before any potential component styles
|
|
2
|
+
import "./main.css"
|
|
3
|
+
|
|
4
|
+
import { StrictMode } from "react"
|
|
5
|
+
import { createRoot } from "react-dom/client"
|
|
6
|
+
import { App } from "./App"
|
|
7
|
+
|
|
8
|
+
createRoot(document.getElementById("root")!).render(
|
|
9
|
+
<StrictMode>
|
|
10
|
+
<App />
|
|
11
|
+
</StrictMode>,
|
|
12
|
+
)
|
|
13
|
+
|