@app-connect/core 1.7.18 → 1.7.20

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.
Files changed (60) hide show
  1. package/connector/proxy/index.js +2 -1
  2. package/handlers/auth.js +30 -55
  3. package/handlers/log.js +182 -10
  4. package/handlers/plugin.js +27 -0
  5. package/handlers/user.js +31 -2
  6. package/index.js +115 -22
  7. package/lib/authSession.js +21 -12
  8. package/lib/callLogComposer.js +1 -1
  9. package/lib/debugTracer.js +20 -2
  10. package/lib/util.js +21 -4
  11. package/mcp/README.md +395 -0
  12. package/mcp/mcpHandler.js +318 -82
  13. package/mcp/tools/checkAuthStatus.js +28 -35
  14. package/mcp/tools/createCallLog.js +13 -9
  15. package/mcp/tools/createContact.js +2 -6
  16. package/mcp/tools/doAuth.js +27 -157
  17. package/mcp/tools/findContactByName.js +6 -9
  18. package/mcp/tools/findContactByPhone.js +2 -6
  19. package/mcp/tools/getGoogleFilePicker.js +5 -9
  20. package/mcp/tools/getHelp.js +2 -3
  21. package/mcp/tools/getPublicConnectors.js +55 -24
  22. package/mcp/tools/index.js +11 -36
  23. package/mcp/tools/logout.js +32 -13
  24. package/mcp/tools/rcGetCallLogs.js +3 -20
  25. package/mcp/ui/App/App.tsx +358 -0
  26. package/mcp/ui/App/components/AuthInfoForm.tsx +113 -0
  27. package/mcp/ui/App/components/AuthSuccess.tsx +22 -0
  28. package/mcp/ui/App/components/ConnectorList.tsx +82 -0
  29. package/mcp/ui/App/components/DebugPanel.tsx +43 -0
  30. package/mcp/ui/App/components/OAuthConnect.tsx +270 -0
  31. package/mcp/ui/App/lib/callTool.ts +130 -0
  32. package/mcp/ui/App/lib/debugLog.ts +41 -0
  33. package/mcp/ui/App/lib/developerPortal.ts +111 -0
  34. package/mcp/ui/App/main.css +6 -0
  35. package/mcp/ui/App/root.tsx +13 -0
  36. package/mcp/ui/dist/index.html +53 -0
  37. package/mcp/ui/index.html +13 -0
  38. package/mcp/ui/package-lock.json +6356 -0
  39. package/mcp/ui/package.json +25 -0
  40. package/mcp/ui/tsconfig.json +26 -0
  41. package/mcp/ui/vite.config.ts +16 -0
  42. package/models/llmSessionModel.js +14 -0
  43. package/models/userModel.js +3 -0
  44. package/package.json +2 -2
  45. package/releaseNotes.json +24 -0
  46. package/test/handlers/auth.test.js +31 -0
  47. package/test/handlers/plugin.test.js +287 -0
  48. package/test/lib/util.test.js +379 -1
  49. package/test/mcp/tools/createCallLog.test.js +3 -3
  50. package/test/mcp/tools/doAuth.test.js +40 -303
  51. package/test/mcp/tools/findContactByName.test.js +3 -3
  52. package/test/mcp/tools/findContactByPhone.test.js +3 -3
  53. package/test/mcp/tools/getGoogleFilePicker.test.js +7 -7
  54. package/test/mcp/tools/getPublicConnectors.test.js +49 -70
  55. package/test/mcp/tools/logout.test.js +17 -11
  56. package/mcp/SupportedPlatforms.md +0 -12
  57. package/mcp/tools/collectAuthInfo.js +0 -91
  58. package/mcp/tools/setConnector.js +0 -69
  59. package/test/mcp/tools/collectAuthInfo.test.js +0 -234
  60. package/test/mcp/tools/setConnector.test.js +0 -177
@@ -4,14 +4,10 @@ const { CallLogModel } = require('../../models/callLogModel');
4
4
 
5
5
  const toolDefinition = {
6
6
  name: 'rcGetCallLogs',
7
- description: '⚠️ REQUIRES AUTHENTICATION: User must first authenticate using the "auth" tool to obtain a JWT token before using this tool. | Get call logs from RingCentral',
7
+ description: '⚠️ REQUIRES CRM CONNECTION. | Get call logs from RingCentral. Returns a `records[]` array. Each item in `records` is a complete RingCentral call log object that can be passed DIRECTLY as `incomingData.logInfo` to the `createCallLog` tool no field renaming or restructuring needed.',
8
8
  inputSchema: {
9
9
  type: 'object',
10
10
  properties: {
11
- jwtToken: {
12
- type: 'string',
13
- description: 'JWT token containing userId and platform information. If user does not have this, direct them to use the "auth" tool first.'
14
- },
15
11
  timeFrom: {
16
12
  type: 'string',
17
13
  description: 'MUST be ISO string. Default is 24 hours ago.'
@@ -21,11 +17,11 @@ const toolDefinition = {
21
17
  description: 'MUST be ISO string. Default is now.'
22
18
  }
23
19
  },
24
- required: ['jwtToken', 'timeFrom', 'timeTo']
20
+ required: []
25
21
  },
26
22
  annotations: {
27
23
  readOnlyHint: true,
28
- openWorldHint: false,
24
+ openWorldHint: true,
29
25
  destructiveHint: false
30
26
  }
31
27
  }
@@ -51,19 +47,6 @@ async function execute(args) {
51
47
  timeFrom: timeFrom ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
52
48
  timeTo: timeTo ?? new Date().toISOString(),
53
49
  });
54
- // hack: remove already logged calls
55
- const existingCalls = [];
56
- for (const call of callLogData.records) {
57
- const existingCallLog = await CallLogModel.findOne({
58
- where: {
59
- sessionId: call.sessionId
60
- }
61
- });
62
- if (existingCallLog) {
63
- existingCalls.push(existingCallLog.sessionId);
64
- }
65
- }
66
- callLogData.records = callLogData.records.filter(call => !existingCalls.includes(call.sessionId));
67
50
  return callLogData;
68
51
  }
69
52
  catch (e) {
@@ -0,0 +1,358 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+ import { ConnectorList, type Connector } from './components/ConnectorList'
3
+ import { AuthInfoForm } from './components/AuthInfoForm'
4
+ import { OAuthConnect } from './components/OAuthConnect'
5
+ import { AuthSuccess } from './components/AuthSuccess'
6
+ import { setServerUrl } from './lib/callTool'
7
+ import { fetchConnectors, fetchManifest } from './lib/developerPortal'
8
+ import { dbg } from './lib/debugLog'
9
+
10
+ // Initial structuredContent from getPublicConnectors — serverUrl, rcAccountId, rcExtensionId, openaiSessionId
11
+ interface ToolOutput {
12
+ serverUrl?: string
13
+ rcAccountId?: string | null
14
+ rcExtensionId?: string | null
15
+ openaiSessionId?: string | null
16
+ error?: boolean
17
+ errorMessage?: string
18
+ }
19
+
20
+ type Step = 'loadingConnectors' | 'select' | 'loading' | 'authInfo' | 'oauth' | 'success' | 'error'
21
+
22
+ interface FlowState {
23
+ connectorManifest: any | null
24
+ connectorName: string | null
25
+ connectorDisplayName: string | null
26
+ hostname: string
27
+ userInfo: any | null
28
+ }
29
+
30
+ const INITIAL_FLOW_STATE: FlowState = {
31
+ connectorManifest: null,
32
+ connectorName: null,
33
+ connectorDisplayName: null,
34
+ hostname: '',
35
+ userInfo: null,
36
+ }
37
+
38
+ /**
39
+ * Extract and apply serverUrl from tool output as early as possible.
40
+ * Must be called synchronously so it's set before any tool calls.
41
+ */
42
+ function applyServerUrl(output: ToolOutput | null) {
43
+ dbg.info('applyServerUrl: received output keys:', output ? Object.keys(output).join(', ') : 'null');
44
+ dbg.info('applyServerUrl: serverUrl =', output?.serverUrl ?? '(none)');
45
+ if (output?.serverUrl) {
46
+ setServerUrl(output.serverUrl);
47
+ }
48
+ return output;
49
+ }
50
+
51
+ /**
52
+ * Hook to receive the initial tool output (serverUrl + rcAccountId).
53
+ *
54
+ * Listens via three mechanisms per the ChatGPT Apps SDK docs:
55
+ * 1. window.openai.toolOutput — synchronous read on mount
56
+ * 2. openai:set_globals event — ChatGPT pushes globals into the iframe
57
+ * 3. ui/notifications/tool-result postMessage — MCP Apps bridge notification
58
+ * 4. Polling window.openai.toolOutput — fallback for async population
59
+ */
60
+ function useToolResult() {
61
+ const [toolResult, setToolResult] = useState<ToolOutput | null>(() => {
62
+ const openai = (window as any).openai
63
+ dbg.info('init: window.openai exists?', !!openai);
64
+ dbg.info('init: window.openai.toolOutput?', JSON.stringify(openai?.toolOutput)?.slice(0, 200) ?? 'null');
65
+ const output = openai?.toolOutput
66
+ if (output && Object.keys(output).length > 0) {
67
+ return applyServerUrl(output)
68
+ }
69
+ return null
70
+ })
71
+
72
+ useEffect(() => {
73
+ if (toolResult) return
74
+
75
+ // MCP Apps bridge: tool-result notification
76
+ const onMessage = (event: MessageEvent) => {
77
+ if (event.source !== window.parent) return
78
+ const message = event.data
79
+ if (!message || message.jsonrpc !== '2.0') return
80
+ if (message.method !== 'ui/notifications/tool-result') return
81
+ const payload = message.params?.structuredContent ?? message.params ?? null
82
+ if (payload) setToolResult(applyServerUrl(payload))
83
+ }
84
+ window.addEventListener('message', onMessage, { passive: true })
85
+
86
+ // ChatGPT extension: openai:set_globals event
87
+ const onSetGlobals = (event: Event) => {
88
+ const detail = (event as CustomEvent).detail
89
+ const output = detail?.globals?.toolOutput ?? (window as any).openai?.toolOutput
90
+ if (output && Object.keys(output).length > 0) {
91
+ setToolResult(applyServerUrl(output))
92
+ }
93
+ }
94
+ window.addEventListener('openai:set_globals', onSetGlobals, { passive: true })
95
+
96
+ // Fallback: poll window.openai.toolOutput for async population
97
+ const interval = setInterval(() => {
98
+ const output = (window as any).openai?.toolOutput
99
+ if (output && Object.keys(output).length > 0) {
100
+ setToolResult(applyServerUrl(output))
101
+ clearInterval(interval)
102
+ }
103
+ }, 100)
104
+
105
+ return () => {
106
+ window.removeEventListener('message', onMessage)
107
+ window.removeEventListener('openai:set_globals', onSetGlobals)
108
+ clearInterval(interval)
109
+ }
110
+ }, [toolResult])
111
+
112
+ return toolResult
113
+ }
114
+
115
+ export function App() {
116
+ const data = useToolResult()
117
+ const [step, setStep] = useState<Step>('loadingConnectors')
118
+ const [connectors, setConnectors] = useState<Connector[]>([])
119
+ const [flow, setFlow] = useState<FlowState>(INITIAL_FLOW_STATE)
120
+ const [errorMsg, setErrorMsg] = useState<string | null>(null)
121
+
122
+ // Fetch connector list from developer portal once serverUrl (and optionally rcAccountId) is known
123
+ useEffect(() => {
124
+ if (!data || data.error) return
125
+
126
+ async function loadConnectors() {
127
+ try {
128
+ dbg.info('loadConnectors: fetching with rcAccountId=', data!.rcAccountId ?? '(none)')
129
+ const list = await fetchConnectors(data!.rcAccountId)
130
+ setConnectors(list)
131
+ setStep('select')
132
+ } catch (err: any) {
133
+ dbg.error('loadConnectors failed:', err.message)
134
+ setStep('error')
135
+ setErrorMsg(err.message || 'Failed to load connectors')
136
+ }
137
+ }
138
+
139
+ loadConnectors()
140
+ }, [data])
141
+
142
+ const resetToSelect = useCallback(() => {
143
+ setStep('select')
144
+ setFlow(INITIAL_FLOW_STATE)
145
+ setErrorMsg(null)
146
+ }, [])
147
+
148
+ const handleConnectorSelect = useCallback(async (connector: Connector) => {
149
+ dbg.info('connector selected:', JSON.stringify(connector))
150
+ setStep('loading')
151
+ setErrorMsg(null)
152
+
153
+ try {
154
+ const manifest = await fetchManifest(
155
+ connector.id,
156
+ connector.status === 'private',
157
+ data?.rcAccountId,
158
+ )
159
+
160
+ if (!manifest) {
161
+ setStep('error')
162
+ setErrorMsg('Failed to load connector configuration')
163
+ return
164
+ }
165
+
166
+ const connectorName = connector.name
167
+ const platform = manifest?.platforms?.[connectorName]
168
+
169
+ if (!platform) {
170
+ setStep('error')
171
+ setErrorMsg('Invalid connector configuration')
172
+ return
173
+ }
174
+
175
+ setFlow((prev) => ({
176
+ ...prev,
177
+ connectorManifest: manifest,
178
+ connectorName,
179
+ connectorDisplayName: connector.displayName,
180
+ }))
181
+
182
+ const envType = platform.environment?.type
183
+ const authType = platform.auth?.type
184
+
185
+ // For oauth with dynamic/selectable environments, collect auth info first
186
+ if (authType === 'oauth' && (envType === 'dynamic' || envType === 'selectable')) {
187
+ setStep('authInfo')
188
+ return
189
+ }
190
+
191
+ // For fixed environments, extract hostname from the fixed URL
192
+ if (envType === 'fixed' && platform.environment?.url) {
193
+ try {
194
+ const url = new URL(platform.environment.url)
195
+ setFlow((prev) => ({ ...prev, hostname: url.hostname }))
196
+ } catch {
197
+ // hostname stays empty
198
+ }
199
+ }
200
+
201
+ setStep('oauth')
202
+ } catch (err: any) {
203
+ setStep('error')
204
+ setErrorMsg(err.message || 'Failed to load connector')
205
+ }
206
+ }, [data?.rcAccountId])
207
+
208
+ const handleAuthInfoSubmit = useCallback(
209
+ (value: { hostname?: string; selection?: string }) => {
210
+ try {
211
+ const platform = flow.connectorManifest?.platforms?.[flow.connectorName!]
212
+ const envType = platform?.environment?.type
213
+ let resolvedHostname = ''
214
+
215
+ if (envType === 'dynamic' && value.hostname) {
216
+ resolvedHostname = new URL(value.hostname).hostname
217
+ } else if (envType === 'selectable' && value.selection) {
218
+ const sel = platform.environment.selections?.find(
219
+ (s: { name: string; const: string }) => s.name === value.selection,
220
+ )
221
+ if (sel?.const) {
222
+ resolvedHostname = new URL(sel.const).hostname
223
+ }
224
+ }
225
+
226
+ setFlow((prev) => ({ ...prev, hostname: resolvedHostname }))
227
+ setStep('oauth')
228
+ } catch (err: any) {
229
+ setStep('error')
230
+ setErrorMsg(err.message || 'Failed to process environment selection')
231
+ }
232
+ },
233
+ [flow.connectorManifest, flow.connectorName],
234
+ )
235
+
236
+ const handleAuthSuccess = useCallback(
237
+ (authData: { jwtToken?: string; userInfo?: any }) => {
238
+ setFlow((prev) => ({ ...prev, userInfo: authData.userInfo ?? null }))
239
+ setStep('success')
240
+ },
241
+ [],
242
+ )
243
+
244
+ const handleAuthError = useCallback(
245
+ (error: string) => {
246
+ if (error === 'retry') {
247
+ setStep('oauth')
248
+ return
249
+ }
250
+ setStep('error')
251
+ setErrorMsg(error)
252
+ },
253
+ [],
254
+ )
255
+
256
+ // Waiting for initial tool output from ChatGPT
257
+ if (!data) {
258
+ return (
259
+ <div className="flex items-center justify-center min-h-[150px] p-4">
260
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
261
+ </div>
262
+ )
263
+ }
264
+
265
+ // Error from initial tool call
266
+ if (data.error) {
267
+ return (
268
+ <div className="p-4">
269
+ <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
270
+ <p className="text-yellow-800">{data.errorMessage || 'An error occurred'}</p>
271
+ </div>
272
+ </div>
273
+ )
274
+ }
275
+
276
+ return (
277
+ <div className="p-4">
278
+ {step === 'loadingConnectors' && (
279
+ <div className="flex items-center justify-center min-h-[150px]">
280
+ <div className="text-center space-y-3">
281
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
282
+ <p className="text-sm text-secondary">Loading connectors...</p>
283
+ </div>
284
+ </div>
285
+ )}
286
+
287
+ {step === 'select' && (
288
+ <ConnectorList connectors={connectors} onSelect={handleConnectorSelect} />
289
+ )}
290
+
291
+ {step === 'loading' && (
292
+ <div className="flex items-center justify-center min-h-[150px]">
293
+ <div className="text-center space-y-3">
294
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
295
+ <p className="text-sm text-secondary">Loading...</p>
296
+ </div>
297
+ </div>
298
+ )}
299
+
300
+ {step === 'authInfo' && flow.connectorManifest && flow.connectorName && (
301
+ <AuthInfoForm
302
+ environmentType={
303
+ flow.connectorManifest.platforms[flow.connectorName].environment.type
304
+ }
305
+ urlIdentifier={
306
+ flow.connectorManifest.platforms[flow.connectorName].environment.urlIdentifier
307
+ }
308
+ instructions={
309
+ flow.connectorManifest.platforms[flow.connectorName].environment.instructions
310
+ }
311
+ selections={
312
+ flow.connectorManifest.platforms[flow.connectorName].environment.selections
313
+ }
314
+ connectorDisplayName={flow.connectorDisplayName!}
315
+ onSubmit={handleAuthInfoSubmit}
316
+ onBack={resetToSelect}
317
+ />
318
+ )}
319
+
320
+ {step === 'oauth' && flow.connectorManifest && flow.connectorName && (
321
+ <OAuthConnect
322
+ connectorManifest={flow.connectorManifest}
323
+ connectorName={flow.connectorName}
324
+ connectorDisplayName={flow.connectorDisplayName!}
325
+ hostname={flow.hostname}
326
+ openaiSessionId={data?.openaiSessionId ?? null}
327
+ rcExtensionId={data?.rcExtensionId ?? null}
328
+ onSuccess={handleAuthSuccess}
329
+ onError={handleAuthError}
330
+ onBack={resetToSelect}
331
+ />
332
+ )}
333
+
334
+ {step === 'success' && (
335
+ <AuthSuccess
336
+ connectorDisplayName={flow.connectorDisplayName!}
337
+ userInfo={flow.userInfo ?? undefined}
338
+ />
339
+ )}
340
+
341
+ {step === 'error' && (
342
+ <div className="w-full max-w-md">
343
+ <div className="rounded-lg border border-red-200 bg-red-50 p-4 mb-4">
344
+ <p className="text-red-700 text-sm font-semibold">Error</p>
345
+ <p className="text-red-700 text-sm mt-1">{errorMsg || 'An error occurred'}</p>
346
+ </div>
347
+ <button
348
+ type="button"
349
+ onClick={resetToSelect}
350
+ className="text-sm text-primary hover:underline cursor-pointer"
351
+ >
352
+ &larr; Back to connector list
353
+ </button>
354
+ </div>
355
+ )}
356
+ </div>
357
+ )
358
+ }
@@ -0,0 +1,113 @@
1
+ import { useState } from 'react'
2
+ import { Button } from '@openai/apps-sdk-ui/components/Button'
3
+ import { Input } from '@openai/apps-sdk-ui/components/Input'
4
+
5
+ interface Selection {
6
+ name: string
7
+ const: string
8
+ }
9
+
10
+ interface AuthInfoFormProps {
11
+ environmentType: 'dynamic' | 'selectable'
12
+ urlIdentifier?: string
13
+ instructions?: string
14
+ selections?: Selection[]
15
+ connectorDisplayName: string
16
+ onSubmit: (value: { hostname?: string; selection?: string }) => void
17
+ onBack: () => void
18
+ }
19
+
20
+ export function AuthInfoForm({
21
+ environmentType,
22
+ urlIdentifier,
23
+ instructions,
24
+ selections,
25
+ connectorDisplayName,
26
+ onSubmit,
27
+ onBack,
28
+ }: AuthInfoFormProps) {
29
+ const [hostname, setHostname] = useState('')
30
+ const [selectedName, setSelectedName] = useState<string | null>(null)
31
+
32
+ const handleSubmit = (e: React.FormEvent) => {
33
+ e.preventDefault()
34
+ if (environmentType === 'dynamic') {
35
+ onSubmit({ hostname })
36
+ } else if (selectedName) {
37
+ onSubmit({ selection: selectedName })
38
+ }
39
+ }
40
+
41
+ return (
42
+ <div className="w-full max-w-md">
43
+ <div className="mb-4">
44
+ <button
45
+ type="button"
46
+ onClick={onBack}
47
+ className="text-sm text-secondary hover:text-primary transition-colors mb-2 cursor-pointer"
48
+ >
49
+ &larr; Back
50
+ </button>
51
+ <h2 className="heading-lg">Connect to {connectorDisplayName}</h2>
52
+ </div>
53
+
54
+ <form onSubmit={handleSubmit} className="space-y-4">
55
+ {environmentType === 'dynamic' && (
56
+ <div className="space-y-2">
57
+ <p className="text-secondary text-sm">
58
+ {instructions || `Enter your ${connectorDisplayName} hostname`}
59
+ </p>
60
+ {urlIdentifier && (
61
+ <p className="text-xs text-secondary opacity-70">
62
+ Example: {urlIdentifier}
63
+ </p>
64
+ )}
65
+ <Input
66
+ type="url"
67
+ value={hostname}
68
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setHostname(e.target.value)}
69
+ placeholder="https://your-instance.example.com"
70
+ size="md"
71
+ />
72
+ </div>
73
+ )}
74
+
75
+ {environmentType === 'selectable' && selections && (
76
+ <div className="space-y-2">
77
+ <p className="text-secondary text-sm">
78
+ Select your {connectorDisplayName} environment
79
+ </p>
80
+ <div className="space-y-2">
81
+ {selections.map((sel) => (
82
+ <button
83
+ key={sel.name}
84
+ type="button"
85
+ onClick={() => setSelectedName(sel.name)}
86
+ className={`w-full text-left rounded-xl border p-3 transition-colors cursor-pointer ${
87
+ selectedName === sel.name
88
+ ? 'border-primary bg-surface-hover'
89
+ : 'border-default bg-surface hover:border-primary'
90
+ }`}
91
+ >
92
+ <span className="font-medium text-sm">{sel.name}</span>
93
+ </button>
94
+ ))}
95
+ </div>
96
+ </div>
97
+ )}
98
+
99
+ <Button
100
+ type="submit"
101
+ color="primary"
102
+ size="md"
103
+ disabled={
104
+ (environmentType === 'dynamic' && !hostname.trim()) ||
105
+ (environmentType === 'selectable' && !selectedName)
106
+ }
107
+ >
108
+ Continue
109
+ </Button>
110
+ </form>
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,22 @@
1
+ interface AuthSuccessProps {
2
+ connectorDisplayName: string
3
+ userInfo?: { id?: string; name?: string }
4
+ }
5
+
6
+ export function AuthSuccess({ connectorDisplayName, userInfo }: AuthSuccessProps) {
7
+ return (
8
+ <div className="w-full max-w-md">
9
+ <div className="rounded-xl border border-green-200 bg-green-50 p-5 text-center space-y-2">
10
+ <div className="text-2xl text-green-700">&#10003;</div>
11
+ <h2 className="heading-lg text-green-800">Connected</h2>
12
+ <p className="text-sm text-green-700">
13
+ Successfully connected to <strong>{connectorDisplayName}</strong>
14
+ {userInfo?.name ? ` as ${userInfo.name}` : ''}.
15
+ </p>
16
+ <p className="text-xs text-green-600 mt-2">
17
+ You can now use the AI assistant to interact with your CRM.
18
+ </p>
19
+ </div>
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,82 @@
1
+ import { useState } from 'react'
2
+ import { Button } from "@openai/apps-sdk-ui/components/Button"
3
+ import { Badge } from "@openai/apps-sdk-ui/components/Badge"
4
+
5
+ export interface Connector {
6
+ id: string
7
+ name: string
8
+ displayName: string
9
+ description?: string
10
+ status?: 'public' | 'private'
11
+ }
12
+
13
+ interface ConnectorListProps {
14
+ connectors: Connector[]
15
+ disabled?: boolean
16
+ onSelect: (connector: Connector) => void
17
+ }
18
+
19
+ export function ConnectorList({ connectors, disabled, onSelect }: ConnectorListProps) {
20
+ const [selectedName, setSelectedName] = useState<string | null>(null)
21
+
22
+ if (!connectors || connectors.length === 0) {
23
+ return (
24
+ <div className="p-4 text-center text-secondary">
25
+ <p>No connectors available</p>
26
+ </div>
27
+ )
28
+ }
29
+
30
+ const handleSelect = (connector: Connector) => {
31
+ setSelectedName(connector.displayName)
32
+ onSelect(connector)
33
+ }
34
+
35
+ return (
36
+ <div className="w-full max-w-md">
37
+ <div className="mb-4">
38
+ <h2 className="heading-lg">Available Connectors</h2>
39
+ <p className="text-secondary text-sm mt-1">
40
+ Select a CRM to connect with RingCentral
41
+ </p>
42
+ </div>
43
+
44
+ <div className="space-y-3">
45
+ {connectors.map((connector) => {
46
+ const isSelected = selectedName === connector.displayName
47
+ return (
48
+ <div
49
+ key={connector.displayName}
50
+ className="rounded-xl border border-default bg-surface p-4 hover:border-primary transition-colors"
51
+ >
52
+ <div className="flex items-center justify-between gap-3">
53
+ <div className="flex-1">
54
+ <div className="flex items-center gap-2">
55
+ <h3 className="font-medium">{connector.displayName}</h3>
56
+ {connector.status === 'private' && (
57
+ <Badge color="secondary" size="sm">Private</Badge>
58
+ )}
59
+ </div>
60
+ {connector.description && (
61
+ <p className="text-secondary text-sm mt-1">
62
+ {connector.description}
63
+ </p>
64
+ )}
65
+ </div>
66
+ <Button
67
+ color={isSelected ? 'secondary' : 'primary'}
68
+ size="sm"
69
+ onClick={() => handleSelect(connector)}
70
+ disabled={disabled || isSelected}
71
+ >
72
+ {isSelected ? 'Connecting...' : 'Connect'}
73
+ </Button>
74
+ </div>
75
+ </div>
76
+ )
77
+ })}
78
+ </div>
79
+ </div>
80
+ )
81
+ }
82
+
@@ -0,0 +1,43 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { getEntries, subscribe, type DebugEntry } from '../lib/debugLog'
3
+
4
+ const LEVEL_STYLE: Record<DebugEntry['level'], string> = {
5
+ info: 'text-blue-700',
6
+ warn: 'text-yellow-700',
7
+ error: 'text-red-700 font-semibold',
8
+ }
9
+
10
+ export function DebugPanel() {
11
+ const [entries, setEntries] = useState(getEntries)
12
+ const [open, setOpen] = useState(false)
13
+
14
+ useEffect(() => subscribe(() => setEntries(getEntries())), [])
15
+
16
+ return (
17
+ <div className="mt-4 border border-default rounded-lg overflow-hidden text-xs">
18
+ <button
19
+ type="button"
20
+ onClick={() => setOpen((v) => !v)}
21
+ className="w-full flex items-center justify-between px-3 py-2 bg-surface hover:bg-surface-hover cursor-pointer text-secondary"
22
+ >
23
+ <span>Debug log ({entries.length} entries)</span>
24
+ <span>{open ? '▲' : '▼'}</span>
25
+ </button>
26
+
27
+ {open && (
28
+ <div className="max-h-60 overflow-y-auto bg-black/5 p-2 space-y-0.5 font-mono">
29
+ {entries.length === 0 && (
30
+ <p className="text-secondary italic">No entries yet.</p>
31
+ )}
32
+ {entries.map((e, i) => (
33
+ <div key={i} className={`${LEVEL_STYLE[e.level]} leading-tight`}>
34
+ <span className="opacity-50 mr-1">{e.time}</span>
35
+ <span>[{e.level}]</span>{' '}
36
+ <span className="break-all">{e.msg}</span>
37
+ </div>
38
+ ))}
39
+ </div>
40
+ )}
41
+ </div>
42
+ )
43
+ }