apteva 0.2.7 → 0.2.9

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 (46) hide show
  1. package/dist/App.m4hg4bxq.js +218 -0
  2. package/dist/index.html +4 -2
  3. package/dist/styles.css +1 -1
  4. package/package.json +1 -1
  5. package/src/auth/index.ts +386 -0
  6. package/src/auth/middleware.ts +183 -0
  7. package/src/binary.ts +19 -1
  8. package/src/db.ts +688 -45
  9. package/src/integrations/composio.ts +437 -0
  10. package/src/integrations/index.ts +80 -0
  11. package/src/openapi.ts +1724 -0
  12. package/src/routes/api.ts +1476 -118
  13. package/src/routes/auth.ts +242 -0
  14. package/src/server.ts +121 -11
  15. package/src/web/App.tsx +64 -19
  16. package/src/web/components/agents/AgentCard.tsx +24 -22
  17. package/src/web/components/agents/AgentPanel.tsx +810 -45
  18. package/src/web/components/agents/AgentsView.tsx +81 -9
  19. package/src/web/components/agents/CreateAgentModal.tsx +28 -1
  20. package/src/web/components/api/ApiDocsPage.tsx +583 -0
  21. package/src/web/components/auth/CreateAccountStep.tsx +176 -0
  22. package/src/web/components/auth/LoginPage.tsx +91 -0
  23. package/src/web/components/auth/index.ts +2 -0
  24. package/src/web/components/common/Icons.tsx +56 -0
  25. package/src/web/components/common/Modal.tsx +184 -1
  26. package/src/web/components/dashboard/Dashboard.tsx +70 -22
  27. package/src/web/components/index.ts +3 -0
  28. package/src/web/components/layout/Header.tsx +135 -18
  29. package/src/web/components/layout/Sidebar.tsx +87 -43
  30. package/src/web/components/mcp/IntegrationsPanel.tsx +743 -0
  31. package/src/web/components/mcp/McpPage.tsx +451 -63
  32. package/src/web/components/onboarding/OnboardingWizard.tsx +64 -8
  33. package/src/web/components/settings/SettingsPage.tsx +340 -26
  34. package/src/web/components/tasks/TasksPage.tsx +22 -20
  35. package/src/web/components/telemetry/TelemetryPage.tsx +163 -61
  36. package/src/web/context/AuthContext.tsx +230 -0
  37. package/src/web/context/ProjectContext.tsx +182 -0
  38. package/src/web/context/index.ts +5 -0
  39. package/src/web/hooks/useAgents.ts +18 -6
  40. package/src/web/hooks/useOnboarding.ts +20 -4
  41. package/src/web/hooks/useProviders.ts +15 -5
  42. package/src/web/icon.png +0 -0
  43. package/src/web/index.html +1 -1
  44. package/src/web/styles.css +12 -0
  45. package/src/web/types.ts +10 -1
  46. package/dist/App.3kb50qa3.js +0 -213
@@ -0,0 +1,743 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { useAuth } from "../../context";
3
+
4
+ // Types
5
+ interface IntegrationApp {
6
+ id: string;
7
+ name: string;
8
+ slug: string;
9
+ description: string | null;
10
+ logo: string | null;
11
+ categories: string[];
12
+ authSchemes: string[];
13
+ }
14
+
15
+ interface ConnectedAccount {
16
+ id: string;
17
+ appId: string;
18
+ appName: string;
19
+ status: "active" | "pending" | "failed" | "expired";
20
+ createdAt: string;
21
+ }
22
+
23
+ interface IntegrationProvider {
24
+ id: string;
25
+ name: string;
26
+ connected: boolean;
27
+ }
28
+
29
+ // Check if app supports API_KEY auth
30
+ function supportsApiKey(app: IntegrationApp): boolean {
31
+ return app.authSchemes.some(s => s.toUpperCase() === "API_KEY");
32
+ }
33
+
34
+ // Check if app supports OAuth
35
+ function supportsOAuth(app: IntegrationApp): boolean {
36
+ return app.authSchemes.some(s => s.toUpperCase() === "OAUTH2");
37
+ }
38
+
39
+ // Check if app supports multiple auth methods
40
+ function hasMultipleAuthMethods(app: IntegrationApp): boolean {
41
+ return supportsApiKey(app) && supportsOAuth(app);
42
+ }
43
+
44
+ // Main component
45
+ export function IntegrationsPanel({
46
+ providerId = "composio",
47
+ onConnectionComplete,
48
+ }: {
49
+ providerId?: string;
50
+ onConnectionComplete?: () => void;
51
+ }) {
52
+ const { authFetch } = useAuth();
53
+ const [apps, setApps] = useState<IntegrationApp[]>([]);
54
+ const [connectedAccounts, setConnectedAccounts] = useState<ConnectedAccount[]>([]);
55
+ const [loading, setLoading] = useState(true);
56
+ const [search, setSearch] = useState("");
57
+ const [connecting, setConnecting] = useState<string | null>(null);
58
+ const [pendingConnection, setPendingConnection] = useState<{
59
+ appSlug: string;
60
+ connectionId?: string;
61
+ } | null>(null);
62
+ const [error, setError] = useState<string | null>(null);
63
+ // For auth method selection (when app supports both OAuth and API Key)
64
+ const [authMethodModal, setAuthMethodModal] = useState<{ app: IntegrationApp } | null>(null);
65
+ // For API Key modal
66
+ const [apiKeyModal, setApiKeyModal] = useState<{ app: IntegrationApp } | null>(null);
67
+ const [apiKeyInput, setApiKeyInput] = useState("");
68
+ // For MCP config creation modal
69
+ const [mcpConfigModal, setMcpConfigModal] = useState<{ app: IntegrationApp } | null>(null);
70
+ const [mcpConfigName, setMcpConfigName] = useState("");
71
+ const [mcpConfigCreating, setMcpConfigCreating] = useState(false);
72
+ const [mcpConfigSuccess, setMcpConfigSuccess] = useState<string | null>(null);
73
+ // For confirmation modal
74
+ const [confirmModal, setConfirmModal] = useState<{
75
+ message: string;
76
+ onConfirm: () => void;
77
+ } | null>(null);
78
+
79
+ // Fetch apps and connected accounts
80
+ const fetchData = useCallback(async () => {
81
+ setLoading(true);
82
+ setError(null);
83
+ try {
84
+ const [appsRes, connectedRes] = await Promise.all([
85
+ authFetch(`/api/integrations/${providerId}/apps`),
86
+ authFetch(`/api/integrations/${providerId}/connected`),
87
+ ]);
88
+
89
+ const appsData = await appsRes.json();
90
+ const connectedData = await connectedRes.json();
91
+
92
+ setApps(appsData.apps || []);
93
+ setConnectedAccounts(connectedData.accounts || []);
94
+ } catch (e) {
95
+ console.error("Failed to fetch integrations:", e);
96
+ setError("Failed to load integrations");
97
+ }
98
+ setLoading(false);
99
+ }, [authFetch, providerId]);
100
+
101
+ useEffect(() => {
102
+ fetchData();
103
+ }, [fetchData]);
104
+
105
+ // Check for connection completion from URL params
106
+ useEffect(() => {
107
+ const params = new URLSearchParams(window.location.search);
108
+ const connectedApp = params.get("connected");
109
+ if (connectedApp) {
110
+ // Remove the query param
111
+ window.history.replaceState({}, "", window.location.pathname);
112
+ // Refresh to show new connection
113
+ fetchData();
114
+ onConnectionComplete?.();
115
+ }
116
+ }, [fetchData, onConnectionComplete]);
117
+
118
+ // Poll for pending connection status
119
+ useEffect(() => {
120
+ if (!pendingConnection?.connectionId) return;
121
+
122
+ const pollInterval = setInterval(async () => {
123
+ try {
124
+ const res = await authFetch(
125
+ `/api/integrations/${providerId}/connection/${pendingConnection.connectionId}`
126
+ );
127
+ const data = await res.json();
128
+
129
+ if (data.connection?.status === "active") {
130
+ setPendingConnection(null);
131
+ setConnecting(null);
132
+ fetchData();
133
+ onConnectionComplete?.();
134
+ } else if (data.connection?.status === "failed") {
135
+ setPendingConnection(null);
136
+ setConnecting(null);
137
+ setError(`Connection to ${pendingConnection.appSlug} failed`);
138
+ }
139
+ } catch (e) {
140
+ // Keep polling
141
+ }
142
+ }, 2000);
143
+
144
+ return () => clearInterval(pollInterval);
145
+ }, [pendingConnection, authFetch, providerId, fetchData, onConnectionComplete]);
146
+
147
+ // Initiate connection
148
+ const connectApp = async (app: IntegrationApp, apiKey?: string, forceOAuth?: boolean) => {
149
+ // If app supports multiple auth methods and user hasn't chosen, show choice
150
+ if (hasMultipleAuthMethods(app) && !apiKey && !forceOAuth) {
151
+ setAuthMethodModal({ app });
152
+ return;
153
+ }
154
+
155
+ // If app supports API key (and user didn't choose OAuth), show API key modal
156
+ if (supportsApiKey(app) && !apiKey && !forceOAuth) {
157
+ setApiKeyModal({ app });
158
+ setApiKeyInput("");
159
+ return;
160
+ }
161
+
162
+ setConnecting(app.slug);
163
+ setError(null);
164
+
165
+ try {
166
+ // Build request body
167
+ const body: any = { appSlug: app.slug };
168
+ if (apiKey) {
169
+ body.credentials = {
170
+ authScheme: "API_KEY",
171
+ apiKey,
172
+ };
173
+ }
174
+
175
+ const res = await authFetch(`/api/integrations/${providerId}/connect`, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json" },
178
+ body: JSON.stringify(body),
179
+ });
180
+
181
+ const data = await res.json();
182
+
183
+ if (!res.ok) {
184
+ setError(data.error || "Failed to initiate connection");
185
+ setConnecting(null);
186
+ setApiKeyModal(null);
187
+ return;
188
+ }
189
+
190
+ // API_KEY connections are immediately active (no redirect)
191
+ if (data.status === "active" || !data.redirectUrl) {
192
+ setConnecting(null);
193
+ setApiKeyModal(null);
194
+ fetchData();
195
+ onConnectionComplete?.();
196
+ return;
197
+ }
198
+
199
+ if (data.redirectUrl) {
200
+ // Store pending connection for polling
201
+ setPendingConnection({
202
+ appSlug: app.slug,
203
+ connectionId: data.connectionId,
204
+ });
205
+
206
+ // Open OAuth in popup
207
+ const popup = window.open(
208
+ data.redirectUrl,
209
+ `connect-${app.slug}`,
210
+ "width=600,height=700,left=200,top=100"
211
+ );
212
+
213
+ // If popup blocked, redirect instead
214
+ if (!popup || popup.closed) {
215
+ window.location.href = data.redirectUrl;
216
+ }
217
+ }
218
+ } catch (e) {
219
+ setError(`Failed to connect: ${e}`);
220
+ setConnecting(null);
221
+ setApiKeyModal(null);
222
+ }
223
+ };
224
+
225
+ // Handle API key form submission
226
+ const handleApiKeySubmit = (e: React.FormEvent) => {
227
+ e.preventDefault();
228
+ if (!apiKeyModal || !apiKeyInput.trim()) return;
229
+ connectApp(apiKeyModal.app, apiKeyInput.trim());
230
+ };
231
+
232
+ // Disconnect (called after confirmation)
233
+ const disconnectApp = async (account: ConnectedAccount) => {
234
+ try {
235
+ const res = await authFetch(
236
+ `/api/integrations/${providerId}/connection/${account.id}`,
237
+ { method: "DELETE" }
238
+ );
239
+
240
+ if (res.ok) {
241
+ fetchData();
242
+ } else {
243
+ const data = await res.json();
244
+ setError(data.error || "Failed to disconnect");
245
+ }
246
+ } catch (e) {
247
+ setError(`Failed to disconnect: ${e}`);
248
+ }
249
+ };
250
+
251
+ // Open MCP config creation modal
252
+ const openMcpConfigModal = (app: IntegrationApp) => {
253
+ setMcpConfigModal({ app });
254
+ setMcpConfigName(`${app.name} MCP`);
255
+ setMcpConfigSuccess(null);
256
+ };
257
+
258
+ // Create MCP config from connected app
259
+ const createMcpConfig = async () => {
260
+ if (!mcpConfigModal || !mcpConfigName.trim()) return;
261
+
262
+ setMcpConfigCreating(true);
263
+ setError(null);
264
+
265
+ try {
266
+ const res = await authFetch(`/api/integrations/${providerId}/configs`, {
267
+ method: "POST",
268
+ headers: { "Content-Type": "application/json" },
269
+ body: JSON.stringify({
270
+ name: mcpConfigName.replace(/[^a-zA-Z0-9\s-]/g, "").substring(0, 30),
271
+ toolkitSlug: mcpConfigModal.app.slug,
272
+ }),
273
+ });
274
+
275
+ const data = await res.json();
276
+
277
+ if (!res.ok) {
278
+ setError(data.error || "Failed to create MCP config");
279
+ setMcpConfigCreating(false);
280
+ return;
281
+ }
282
+
283
+ setMcpConfigSuccess(mcpConfigName);
284
+ onConnectionComplete?.();
285
+ } catch (e) {
286
+ setError(`Failed to create MCP config: ${e}`);
287
+ } finally {
288
+ setMcpConfigCreating(false);
289
+ }
290
+ };
291
+
292
+ // Handle disconnect with confirmation modal
293
+ const handleDisconnect = (account: ConnectedAccount) => {
294
+ setConfirmModal({
295
+ message: `Disconnect ${account.appName}?`,
296
+ onConfirm: () => {
297
+ disconnectApp(account);
298
+ setConfirmModal(null);
299
+ },
300
+ });
301
+ };
302
+
303
+ // Check if app is connected
304
+ const isConnected = (appSlug: string) => {
305
+ return connectedAccounts.some(
306
+ (a) => a.appId === appSlug && a.status === "active"
307
+ );
308
+ };
309
+
310
+ // Get connection for app
311
+ const getConnection = (appSlug: string) => {
312
+ return connectedAccounts.find((a) => a.appId === appSlug);
313
+ };
314
+
315
+ // Filter apps
316
+ const filteredApps = apps.filter((app) => {
317
+ if (!search) return true;
318
+ const s = search.toLowerCase();
319
+ return (
320
+ app.name.toLowerCase().includes(s) ||
321
+ app.slug.toLowerCase().includes(s) ||
322
+ app.description?.toLowerCase().includes(s) ||
323
+ app.categories.some((c) => c.toLowerCase().includes(s))
324
+ );
325
+ });
326
+
327
+ // Group by connected/not connected
328
+ const connectedApps = filteredApps.filter((app) => isConnected(app.slug));
329
+ const availableApps = filteredApps.filter((app) => !isConnected(app.slug));
330
+
331
+ if (loading) {
332
+ return <div className="text-center py-8 text-[#666]">Loading apps...</div>;
333
+ }
334
+
335
+ return (
336
+ <div className="space-y-6">
337
+ {/* Auth Method Choice Modal */}
338
+ {authMethodModal && (
339
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
340
+ <div className="bg-[#111] border border-[#333] rounded-lg p-6 w-full max-w-md mx-4">
341
+ <div className="flex items-center gap-3 mb-4">
342
+ {authMethodModal.app.logo && (
343
+ <img
344
+ src={authMethodModal.app.logo}
345
+ alt={authMethodModal.app.name}
346
+ className="w-10 h-10 object-contain"
347
+ />
348
+ )}
349
+ <div>
350
+ <h3 className="font-medium">Connect {authMethodModal.app.name}</h3>
351
+ <p className="text-xs text-[#666]">Choose how to authenticate</p>
352
+ </div>
353
+ </div>
354
+ <div className="space-y-3">
355
+ <button
356
+ onClick={() => {
357
+ setAuthMethodModal(null);
358
+ setApiKeyModal({ app: authMethodModal.app });
359
+ setApiKeyInput("");
360
+ }}
361
+ className="w-full text-left p-3 bg-[#0a0a0a] hover:bg-[#1a1a1a] border border-[#333] hover:border-[#f97316] rounded-lg transition"
362
+ >
363
+ <div className="font-medium text-sm">API Key</div>
364
+ <div className="text-xs text-[#666] mt-0.5">
365
+ Enter your {authMethodModal.app.name} API key directly
366
+ </div>
367
+ </button>
368
+ <button
369
+ onClick={() => {
370
+ setAuthMethodModal(null);
371
+ connectApp(authMethodModal.app, undefined, true);
372
+ }}
373
+ className="w-full text-left p-3 bg-[#0a0a0a] hover:bg-[#1a1a1a] border border-[#333] hover:border-[#f97316] rounded-lg transition"
374
+ >
375
+ <div className="font-medium text-sm">OAuth</div>
376
+ <div className="text-xs text-[#666] mt-0.5">
377
+ Sign in with your {authMethodModal.app.name} account
378
+ </div>
379
+ </button>
380
+ </div>
381
+ <button
382
+ onClick={() => setAuthMethodModal(null)}
383
+ className="w-full text-sm text-[#666] hover:text-white mt-4 py-2 transition"
384
+ >
385
+ Cancel
386
+ </button>
387
+ </div>
388
+ </div>
389
+ )}
390
+
391
+ {/* API Key Modal */}
392
+ {apiKeyModal && (
393
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
394
+ <div className="bg-[#111] border border-[#333] rounded-lg p-6 w-full max-w-md mx-4">
395
+ <div className="flex items-center gap-3 mb-4">
396
+ {apiKeyModal.app.logo && (
397
+ <img
398
+ src={apiKeyModal.app.logo}
399
+ alt={apiKeyModal.app.name}
400
+ className="w-10 h-10 object-contain"
401
+ />
402
+ )}
403
+ <div>
404
+ <h3 className="font-medium">Connect {apiKeyModal.app.name}</h3>
405
+ <p className="text-xs text-[#666]">Enter your API key to connect</p>
406
+ </div>
407
+ </div>
408
+ <form onSubmit={handleApiKeySubmit}>
409
+ <input
410
+ type="password"
411
+ value={apiKeyInput}
412
+ onChange={(e) => setApiKeyInput(e.target.value)}
413
+ placeholder="Enter API Key..."
414
+ className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-4 py-2 mb-4 focus:outline-none focus:border-[#f97316]"
415
+ autoFocus
416
+ />
417
+ <div className="flex gap-2">
418
+ <button
419
+ type="button"
420
+ onClick={() => setApiKeyModal(null)}
421
+ className="flex-1 text-sm bg-[#1a1a1a] hover:bg-[#222] border border-[#333] px-4 py-2 rounded transition"
422
+ >
423
+ Cancel
424
+ </button>
425
+ <button
426
+ type="submit"
427
+ disabled={!apiKeyInput.trim() || connecting === apiKeyModal.app.slug}
428
+ className="flex-1 text-sm bg-[#f97316] hover:bg-[#ea580c] text-white px-4 py-2 rounded transition disabled:opacity-50"
429
+ >
430
+ {connecting === apiKeyModal.app.slug ? "Connecting..." : "Connect"}
431
+ </button>
432
+ </div>
433
+ </form>
434
+ </div>
435
+ </div>
436
+ )}
437
+
438
+ {/* MCP Config Creation Modal */}
439
+ {mcpConfigModal && (
440
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
441
+ <div className="bg-[#111] border border-[#333] rounded-lg p-6 w-full max-w-md mx-4">
442
+ {mcpConfigSuccess ? (
443
+ <>
444
+ <div className="text-center mb-4">
445
+ <div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
446
+ <span className="text-green-400 text-2xl">✓</span>
447
+ </div>
448
+ <h3 className="font-medium text-lg">MCP Config Created!</h3>
449
+ <p className="text-sm text-[#888] mt-2">
450
+ "{mcpConfigSuccess}" has been created successfully.
451
+ </p>
452
+ <p className="text-xs text-[#666] mt-2">
453
+ You can now add it to your agents from the MCP Configs tab.
454
+ </p>
455
+ </div>
456
+ <button
457
+ onClick={() => {
458
+ setMcpConfigModal(null);
459
+ setMcpConfigSuccess(null);
460
+ }}
461
+ className="w-full text-sm bg-[#f97316] hover:bg-[#ea580c] text-white px-4 py-2 rounded transition"
462
+ >
463
+ Done
464
+ </button>
465
+ </>
466
+ ) : (
467
+ <>
468
+ <div className="flex items-center gap-3 mb-4">
469
+ {mcpConfigModal.app.logo && (
470
+ <img
471
+ src={mcpConfigModal.app.logo}
472
+ alt={mcpConfigModal.app.name}
473
+ className="w-10 h-10 object-contain"
474
+ />
475
+ )}
476
+ <div>
477
+ <h3 className="font-medium">Create MCP Config</h3>
478
+ <p className="text-xs text-[#666]">
479
+ Create an MCP config for {mcpConfigModal.app.name}
480
+ </p>
481
+ </div>
482
+ </div>
483
+ <form onSubmit={(e) => { e.preventDefault(); createMcpConfig(); }}>
484
+ <label className="block text-xs text-[#888] mb-1">Config Name</label>
485
+ <input
486
+ type="text"
487
+ value={mcpConfigName}
488
+ onChange={(e) => setMcpConfigName(e.target.value)}
489
+ placeholder="Enter config name..."
490
+ className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-4 py-2 mb-4 focus:outline-none focus:border-[#f97316]"
491
+ autoFocus
492
+ maxLength={30}
493
+ />
494
+ <div className="flex gap-2">
495
+ <button
496
+ type="button"
497
+ onClick={() => setMcpConfigModal(null)}
498
+ className="flex-1 text-sm bg-[#1a1a1a] hover:bg-[#222] border border-[#333] px-4 py-2 rounded transition"
499
+ >
500
+ Cancel
501
+ </button>
502
+ <button
503
+ type="submit"
504
+ disabled={!mcpConfigName.trim() || mcpConfigCreating}
505
+ className="flex-1 text-sm bg-[#f97316] hover:bg-[#ea580c] text-white px-4 py-2 rounded transition disabled:opacity-50"
506
+ >
507
+ {mcpConfigCreating ? "Creating..." : "Create Config"}
508
+ </button>
509
+ </div>
510
+ </form>
511
+ </>
512
+ )}
513
+ </div>
514
+ </div>
515
+ )}
516
+
517
+ {/* Confirmation Modal */}
518
+ {confirmModal && (
519
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
520
+ <div className="bg-[#111] border border-[#333] rounded-lg p-6 w-full max-w-sm mx-4">
521
+ <p className="text-center mb-4">{confirmModal.message}</p>
522
+ <div className="flex gap-2">
523
+ <button
524
+ onClick={() => setConfirmModal(null)}
525
+ className="flex-1 text-sm bg-[#1a1a1a] hover:bg-[#222] border border-[#333] px-4 py-2 rounded transition"
526
+ >
527
+ Cancel
528
+ </button>
529
+ <button
530
+ onClick={confirmModal.onConfirm}
531
+ className="flex-1 text-sm bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded transition"
532
+ >
533
+ Confirm
534
+ </button>
535
+ </div>
536
+ </div>
537
+ </div>
538
+ )}
539
+
540
+ {/* Error */}
541
+ {error && (
542
+ <div className="text-red-400 text-sm p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center justify-between">
543
+ <span>{error}</span>
544
+ <button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
545
+ ×
546
+ </button>
547
+ </div>
548
+ )}
549
+
550
+ {/* Pending connection notice */}
551
+ {pendingConnection && (
552
+ <div className="text-yellow-400 text-sm p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg flex items-center gap-2">
553
+ <span className="animate-spin">⟳</span>
554
+ <span>Waiting for {pendingConnection.appSlug} authorization...</span>
555
+ </div>
556
+ )}
557
+
558
+ {/* Search */}
559
+ <div>
560
+ <input
561
+ type="text"
562
+ value={search}
563
+ onChange={(e) => setSearch(e.target.value)}
564
+ placeholder="Search apps..."
565
+ className="w-full bg-[#111] border border-[#333] rounded-lg px-4 py-2 focus:outline-none focus:border-[#f97316]"
566
+ />
567
+ </div>
568
+
569
+ {/* Connected Apps */}
570
+ {connectedApps.length > 0 && (
571
+ <div>
572
+ <h3 className="text-sm font-medium text-[#888] mb-3">
573
+ Connected ({connectedApps.length})
574
+ </h3>
575
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
576
+ {connectedApps.map((app) => (
577
+ <AppCard
578
+ key={app.id}
579
+ app={app}
580
+ connection={getConnection(app.slug)}
581
+ onConnect={() => connectApp(app)}
582
+ onDisconnect={() => {
583
+ const conn = getConnection(app.slug);
584
+ if (conn) handleDisconnect(conn);
585
+ }}
586
+ onCreateMcpConfig={() => openMcpConfigModal(app)}
587
+ connecting={connecting === app.slug}
588
+ />
589
+ ))}
590
+ </div>
591
+ </div>
592
+ )}
593
+
594
+ {/* Available Apps */}
595
+ <div>
596
+ <h3 className="text-sm font-medium text-[#888] mb-3">
597
+ Available Apps ({availableApps.length})
598
+ </h3>
599
+ {availableApps.length === 0 ? (
600
+ <p className="text-[#666] text-sm">
601
+ {search ? "No apps match your search" : "No apps available"}
602
+ </p>
603
+ ) : (
604
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
605
+ {availableApps.slice(0, 50).map((app) => (
606
+ <AppCard
607
+ key={app.id}
608
+ app={app}
609
+ onConnect={() => connectApp(app)}
610
+ connecting={connecting === app.slug}
611
+ />
612
+ ))}
613
+ </div>
614
+ )}
615
+ {availableApps.length > 50 && (
616
+ <p className="text-xs text-[#555] mt-3 text-center">
617
+ Showing first 50 of {availableApps.length} apps. Use search to find more.
618
+ </p>
619
+ )}
620
+ </div>
621
+ </div>
622
+ );
623
+ }
624
+
625
+ // App card component
626
+ function AppCard({
627
+ app,
628
+ connection,
629
+ onConnect,
630
+ onDisconnect,
631
+ onCreateMcpConfig,
632
+ connecting,
633
+ }: {
634
+ app: IntegrationApp;
635
+ connection?: ConnectedAccount;
636
+ onConnect: () => void;
637
+ onDisconnect?: () => void;
638
+ onCreateMcpConfig?: () => void;
639
+ connecting: boolean;
640
+ }) {
641
+ const isConnected = connection?.status === "active";
642
+ const hasApiKey = supportsApiKey(app);
643
+ const hasOAuth = supportsOAuth(app);
644
+ const hasBothMethods = hasApiKey && hasOAuth;
645
+
646
+ return (
647
+ <div
648
+ className={`bg-[#111] border rounded-lg p-3 transition ${
649
+ isConnected ? "border-green-500/30" : "border-[#1a1a1a] hover:border-[#333]"
650
+ }`}
651
+ >
652
+ <div className="flex items-start gap-3">
653
+ {/* Logo */}
654
+ <div className="w-10 h-10 rounded bg-[#1a1a1a] flex items-center justify-center flex-shrink-0 overflow-hidden">
655
+ {app.logo ? (
656
+ <img
657
+ src={app.logo}
658
+ alt={app.name}
659
+ className="w-8 h-8 object-contain"
660
+ onError={(e) => {
661
+ (e.target as HTMLImageElement).style.display = "none";
662
+ }}
663
+ />
664
+ ) : (
665
+ <span className="text-lg">{app.name[0]?.toUpperCase()}</span>
666
+ )}
667
+ </div>
668
+
669
+ {/* Info */}
670
+ <div className="flex-1 min-w-0">
671
+ <div className="flex items-center gap-2">
672
+ <h4 className="font-medium text-sm truncate">{app.name}</h4>
673
+ {isConnected && (
674
+ <span className="text-xs text-green-400">✓</span>
675
+ )}
676
+ {!isConnected && hasApiKey && !hasOAuth && (
677
+ <span className="text-[10px] bg-[#222] text-[#888] px-1.5 py-0.5 rounded" title="Requires API Key">
678
+ API Key
679
+ </span>
680
+ )}
681
+ {!isConnected && hasBothMethods && (
682
+ <span className="text-[10px] bg-[#1a2a1a] text-[#6a6] px-1.5 py-0.5 rounded" title="Supports API Key or OAuth">
683
+ API Key / OAuth
684
+ </span>
685
+ )}
686
+ </div>
687
+ {app.description && (
688
+ <p className="text-xs text-[#666] line-clamp-2 mt-0.5">
689
+ {app.description}
690
+ </p>
691
+ )}
692
+ {app.categories.length > 0 && (
693
+ <div className="flex flex-wrap gap-1 mt-1">
694
+ {app.categories.slice(0, 2).map((cat) => (
695
+ <span
696
+ key={cat}
697
+ className="text-[10px] bg-[#1a1a1a] text-[#555] px-1.5 py-0.5 rounded"
698
+ >
699
+ {cat}
700
+ </span>
701
+ ))}
702
+ </div>
703
+ )}
704
+ </div>
705
+ </div>
706
+
707
+ {/* Actions */}
708
+ <div className="mt-3 flex gap-2">
709
+ {isConnected ? (
710
+ <>
711
+ {onCreateMcpConfig && (
712
+ <button
713
+ onClick={onCreateMcpConfig}
714
+ className="flex-1 text-xs bg-[#1a2a1a] hover:bg-[#1a3a1a] border border-green-500/30 hover:border-green-500/50 text-green-400 px-3 py-1.5 rounded transition"
715
+ >
716
+ Create MCP Config
717
+ </button>
718
+ )}
719
+ {onDisconnect && (
720
+ <button
721
+ onClick={onDisconnect}
722
+ className="text-xs text-[#666] hover:text-red-400 transition px-2"
723
+ title="Disconnect"
724
+ >
725
+ ×
726
+ </button>
727
+ )}
728
+ </>
729
+ ) : (
730
+ <button
731
+ onClick={onConnect}
732
+ disabled={connecting}
733
+ className="w-full text-xs bg-[#1a1a1a] hover:bg-[#222] border border-[#333] hover:border-[#f97316] px-3 py-1.5 rounded transition disabled:opacity-50"
734
+ >
735
+ {connecting ? "Connecting..." : (hasApiKey && !hasOAuth) ? "Enter API Key" : "Connect"}
736
+ </button>
737
+ )}
738
+ </div>
739
+ </div>
740
+ );
741
+ }
742
+
743
+ export default IntegrationsPanel;