@greatapps/greatagents-ui 0.3.9 → 0.3.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greatapps/greatagents-ui",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Shared agents UI components for Great platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -2,9 +2,7 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import type { GagentsHookConfig } from "../../hooks/types";
5
- import { useToolCredentials } from "../../hooks";
6
5
  import { ToolsTable } from "../tools/tools-table";
7
- import { ToolCredentialsForm } from "../tools/tool-credentials-form";
8
6
  import { ToolFormDialog } from "../tools/tool-form-dialog";
9
7
  import type { Tool } from "../../types";
10
8
  import { Info } from "lucide-react";
@@ -24,10 +22,6 @@ export interface AdvancedTabProps {
24
22
  // ---------------------------------------------------------------------------
25
23
 
26
24
  export function AdvancedTab({ config, agentId, gagentsApiUrl }: AdvancedTabProps) {
27
- const { data: credentialsData, isLoading: isLoadingCredentials } =
28
- useToolCredentials(config);
29
- const credentials = credentialsData?.data ?? [];
30
-
31
25
  const [editingTool, setEditingTool] = useState<Tool | null>(null);
32
26
  const [showToolForm, setShowToolForm] = useState(false);
33
27
 
@@ -49,7 +43,7 @@ export function AdvancedTab({ config, agentId, gagentsApiUrl }: AdvancedTabProps
49
43
  <p className="text-sm text-blue-800 dark:text-blue-300">
50
44
  Use as abas <strong>Capacidades</strong> e <strong>Integrações</strong> para
51
45
  configuração simplificada. Esta aba oferece controlo manual avançado sobre
52
- ferramentas e credenciais.
46
+ ferramentas. As credenciais são geridas dentro de cada ferramenta.
53
47
  </p>
54
48
  </div>
55
49
 
@@ -59,17 +53,6 @@ export function AdvancedTab({ config, agentId, gagentsApiUrl }: AdvancedTabProps
59
53
  <ToolsTable onEdit={handleEditTool} config={config} />
60
54
  </section>
61
55
 
62
- {/* Credenciais section */}
63
- <section className="space-y-3">
64
- <h3 className="text-sm font-medium">Credenciais</h3>
65
- <ToolCredentialsForm
66
- credentials={credentials}
67
- isLoading={isLoadingCredentials}
68
- config={config}
69
- gagentsApiUrl={gagentsApiUrl}
70
- />
71
- </section>
72
-
73
56
  {/* Tool edit dialog */}
74
57
  <ToolFormDialog
75
58
  open={showToolForm}
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback, useRef, useEffect, useMemo } from "react";
3
+ import { useState, useCallback, useEffect, useMemo } from "react";
4
4
  import type { GagentsHookConfig } from "../../hooks/types";
5
5
  import {
6
6
  useCapabilities,
@@ -31,6 +31,7 @@ import {
31
31
  HeartHandshake,
32
32
  Package,
33
33
  ChevronDown,
34
+ Loader2,
34
35
  } from "lucide-react";
35
36
  import { toast } from "sonner";
36
37
 
@@ -129,7 +130,6 @@ export function CapabilitiesTab({ config, agentId }: CapabilitiesTabProps) {
129
130
  const [localState, setLocalState] = useState<CapabilityState>(new Map());
130
131
  const [serverState, setServerState] = useState<CapabilityState>(new Map());
131
132
  const [initialized, setInitialized] = useState(false);
132
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
133
 
134
134
  // Sync server data into local state on first load / refetch
135
135
  useEffect(() => {
@@ -151,41 +151,12 @@ export function CapabilitiesTab({ config, agentId }: CapabilitiesTabProps) {
151
151
  [localState, serverState, initialized],
152
152
  );
153
153
 
154
- // ------ Debounced save ------
155
- const scheduleSave = useCallback(
156
- (nextState: CapabilityState) => {
157
- if (debounceRef.current) clearTimeout(debounceRef.current);
158
- debounceRef.current = setTimeout(() => {
159
- const payload = stateToPayload(nextState);
160
- updateMutation.mutate(
161
- { agentId, payload },
162
- {
163
- onSuccess: () => {
164
- setServerState(cloneState(nextState));
165
- toast.success("Capacidades salvas");
166
- },
167
- onError: () => {
168
- // Rollback to server state
169
- setLocalState(cloneState(serverState));
170
- toast.error("Erro ao salvar capacidades");
171
- },
172
- },
173
- );
174
- }, 500);
175
- },
176
- [agentId, updateMutation, serverState],
177
- );
178
-
179
154
  // ------ State mutation helpers ------
180
155
  const updateState = useCallback(
181
156
  (updater: (prev: CapabilityState) => CapabilityState) => {
182
- setLocalState((prev) => {
183
- const next = updater(prev);
184
- scheduleSave(next);
185
- return next;
186
- });
157
+ setLocalState((prev) => updater(prev));
187
158
  },
188
- [scheduleSave],
159
+ [],
189
160
  );
190
161
 
191
162
  const toggleModule = useCallback(
@@ -242,12 +213,10 @@ export function CapabilitiesTab({ config, agentId }: CapabilitiesTabProps) {
242
213
  }, [updateState]);
243
214
 
244
215
  const discard = useCallback(() => {
245
- if (debounceRef.current) clearTimeout(debounceRef.current);
246
216
  setLocalState(cloneState(serverState));
247
217
  }, [serverState]);
248
218
 
249
219
  const saveNow = useCallback(() => {
250
- if (debounceRef.current) clearTimeout(debounceRef.current);
251
220
  const payload = stateToPayload(localState);
252
221
  updateMutation.mutate(
253
222
  { agentId, payload },
@@ -368,20 +337,15 @@ export function CapabilitiesTab({ config, agentId }: CapabilitiesTabProps) {
368
337
 
369
338
  {/* Save bar */}
370
339
  {hasChanges && (
371
- <div className="sticky bottom-0 bg-background border-t py-3 px-4 -mx-4 flex items-center justify-between">
372
- <span className="text-sm text-muted-foreground">
373
- Você tem alterações não salvas.
374
- </span>
375
- <div className="flex items-center gap-2">
376
- <Button variant="outline" size="sm" onClick={discard}>
340
+ <div className="sticky bottom-0 z-10 flex items-center justify-between gap-2 rounded-lg border bg-background p-3 shadow-sm">
341
+ <p className="text-sm text-muted-foreground">Você tem alterações não salvas.</p>
342
+ <div className="flex gap-2">
343
+ <Button variant="ghost" size="sm" onClick={discard} disabled={updateMutation.isPending}>
377
344
  Descartar
378
345
  </Button>
379
- <Button
380
- size="sm"
381
- onClick={saveNow}
382
- disabled={updateMutation.isPending}
383
- >
384
- {updateMutation.isPending ? "Salvando..." : "Salvar"}
346
+ <Button size="sm" onClick={saveNow} disabled={updateMutation.isPending}>
347
+ {updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
348
+ Salvar
385
349
  </Button>
386
350
  </div>
387
351
  </div>
@@ -9,6 +9,7 @@ import {
9
9
  RefreshCw,
10
10
  Users,
11
11
  Clock,
12
+ Plus,
12
13
  } from "lucide-react";
13
14
  import type { LucideIcon } from "lucide-react";
14
15
  import { cn } from "../../lib";
@@ -24,6 +25,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
24
25
  RefreshCw,
25
26
  Users,
26
27
  Clock,
28
+ Plus,
27
29
  };
28
30
 
29
31
  function resolveIcon(name: string): LucideIcon {
@@ -58,8 +60,9 @@ const STATE_BADGES: Record<IntegrationCardState, BadgeVariant> = {
58
60
  },
59
61
  };
60
62
 
61
- function getActionLabel(state: IntegrationCardState): string {
62
- switch (state) {
63
+ function getActionLabel(card: IntegrationCardData): string {
64
+ if (card.isAddNew) return "Conectar";
65
+ switch (card.state) {
63
66
  case "available":
64
67
  return "Conectar";
65
68
  case "connected":
@@ -81,11 +84,71 @@ export interface IntegrationCardProps {
81
84
  }
82
85
 
83
86
  export function IntegrationCard({ card, onConnect }: IntegrationCardProps) {
84
- const { definition, state, sharedByAgentsCount } = card;
87
+ const { definition, state, sharedByAgentsCount, isAddNew, accountLabel } = card;
85
88
  const Icon = resolveIcon(definition.icon);
86
- const badge = STATE_BADGES[state];
87
- const actionLabel = getActionLabel(state);
88
89
  const isComingSoon = state === "coming_soon";
90
+ const actionLabel = getActionLabel(card);
91
+
92
+ // "Add new" card uses a muted/outlined style
93
+ if (isAddNew) {
94
+ return (
95
+ <div
96
+ className={cn(
97
+ "group relative flex flex-col gap-3 rounded-xl border border-dashed bg-card/50 p-5 transition-shadow",
98
+ "hover:shadow-md hover:border-solid hover:bg-card cursor-pointer",
99
+ )}
100
+ role="button"
101
+ tabIndex={0}
102
+ aria-label={`Adicionar conta ${definition.name}`}
103
+ onClick={() => onConnect(card)}
104
+ onKeyDown={(e) => {
105
+ if (e.key === "Enter" || e.key === " ") {
106
+ e.preventDefault();
107
+ onConnect(card);
108
+ }
109
+ }}
110
+ >
111
+ {/* Header row */}
112
+ <div className="flex items-start justify-between gap-2">
113
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/5 text-primary/60">
114
+ <Icon className="h-5 w-5" />
115
+ </div>
116
+ <Badge variant="outline" className="text-xs bg-muted text-muted-foreground">
117
+ Adicionar
118
+ </Badge>
119
+ </div>
120
+
121
+ {/* Name + description */}
122
+ <div className="space-y-1">
123
+ <h3 className="text-sm font-semibold leading-tight text-muted-foreground">
124
+ {definition.name}
125
+ </h3>
126
+ <p className="text-xs text-muted-foreground/70 leading-relaxed flex items-center gap-1">
127
+ <Plus className="h-3 w-3" />
128
+ Adicionar conta
129
+ </p>
130
+ </div>
131
+
132
+ {/* Footer */}
133
+ <div className="mt-auto flex items-center justify-end pt-1">
134
+ <Button
135
+ variant="outline"
136
+ size="sm"
137
+ className="text-xs"
138
+ onClick={(e) => {
139
+ e.stopPropagation();
140
+ onConnect(card);
141
+ }}
142
+ >
143
+ {actionLabel}
144
+ </Button>
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ // Connected / expired / available card
151
+ const badge = STATE_BADGES[state];
89
152
 
90
153
  return (
91
154
  <div
@@ -97,7 +160,7 @@ export function IntegrationCard({ card, onConnect }: IntegrationCardProps) {
97
160
  )}
98
161
  role="button"
99
162
  tabIndex={isComingSoon ? -1 : 0}
100
- aria-label={`${definition.name} — ${badge.label}`}
163
+ aria-label={`${definition.name}${accountLabel ? ` — ${accountLabel}` : ""} — ${badge.label}`}
101
164
  aria-disabled={isComingSoon}
102
165
  onClick={() => !isComingSoon && onConnect(card)}
103
166
  onKeyDown={(e) => {
@@ -117,12 +180,18 @@ export function IntegrationCard({ card, onConnect }: IntegrationCardProps) {
117
180
  </Badge>
118
181
  </div>
119
182
 
120
- {/* Name + description */}
183
+ {/* Name + account label */}
121
184
  <div className="space-y-1">
122
185
  <h3 className="text-sm font-semibold leading-tight">{definition.name}</h3>
123
- <p className="text-xs text-muted-foreground leading-relaxed">
124
- {definition.description}
125
- </p>
186
+ {accountLabel ? (
187
+ <p className="text-xs text-muted-foreground leading-relaxed truncate" title={accountLabel}>
188
+ {accountLabel}
189
+ </p>
190
+ ) : (
191
+ <p className="text-xs text-muted-foreground leading-relaxed">
192
+ {definition.description}
193
+ </p>
194
+ )}
126
195
  </div>
127
196
 
128
197
  {/* Footer */}
@@ -225,10 +225,15 @@ export function IntegrationWizard({
225
225
 
226
226
  try {
227
227
  // 1. Get auth URL from backend
228
- const response = await fetch(
229
- `${gagentsApiUrl}/v1/${language}/${idWl}/accounts/${accountId}/oauth/authorize/${integration.slug}`,
230
- { headers: { Authorization: `Bearer ${token}` } },
231
- );
228
+ // If reconnecting an existing credential, pass credential_id so the
229
+ // backend updates that specific credential instead of creating a new one.
230
+ let authorizeUrl = `${gagentsApiUrl}/v1/${language}/${idWl}/accounts/${accountId}/oauth/authorize/${integration.slug}`;
231
+ if (existingCredentialId) {
232
+ authorizeUrl += `?credential_id=${existingCredentialId}`;
233
+ }
234
+ const response = await fetch(authorizeUrl, {
235
+ headers: { Authorization: `Bearer ${token}` },
236
+ });
232
237
  const result = await response.json();
233
238
 
234
239
  if (result.status !== 1 || !result.data?.auth_url) {
@@ -17,6 +17,20 @@ export interface IntegrationsTabProps {
17
17
  onConnect: (card: IntegrationCardData) => void;
18
18
  }
19
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function getCardKey(card: IntegrationCardData): string {
25
+ if (card.credentialId) {
26
+ return `${card.definition.slug}-cred-${card.credentialId}`;
27
+ }
28
+ if (card.isAddNew) {
29
+ return `${card.definition.slug}-add-new`;
30
+ }
31
+ return card.definition.slug;
32
+ }
33
+
20
34
  // ---------------------------------------------------------------------------
21
35
  // Component
22
36
  // ---------------------------------------------------------------------------
@@ -47,15 +61,53 @@ export function IntegrationsTab({
47
61
  );
48
62
  }
49
63
 
64
+ // Split into connected/expired cards and add-new/coming-soon cards
65
+ const connectedCards = cards.filter(
66
+ (c) => !c.isAddNew && (c.state === "connected" || c.state === "expired"),
67
+ );
68
+ const otherCards = cards.filter(
69
+ (c) => c.isAddNew || c.state === "coming_soon",
70
+ );
71
+
50
72
  return (
51
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
52
- {cards.map((card) => (
53
- <IntegrationCard
54
- key={card.definition.slug}
55
- card={card}
56
- onConnect={onConnect}
57
- />
58
- ))}
73
+ <div className="space-y-6">
74
+ {/* Connected accounts */}
75
+ {connectedCards.length > 0 && (
76
+ <div>
77
+ <h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
78
+ Contas conectadas
79
+ </h3>
80
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
81
+ {connectedCards.map((card) => (
82
+ <IntegrationCard
83
+ key={getCardKey(card)}
84
+ card={card}
85
+ onConnect={onConnect}
86
+ />
87
+ ))}
88
+ </div>
89
+ </div>
90
+ )}
91
+
92
+ {/* Add new / coming soon */}
93
+ {otherCards.length > 0 && (
94
+ <div>
95
+ {connectedCards.length > 0 && (
96
+ <h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
97
+ Adicionar integração
98
+ </h3>
99
+ )}
100
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
101
+ {otherCards.map((card) => (
102
+ <IntegrationCard
103
+ key={getCardKey(card)}
104
+ card={card}
105
+ onConnect={onConnect}
106
+ />
107
+ ))}
108
+ </div>
109
+ </div>
110
+ )}
59
111
  </div>
60
112
  );
61
113
  }
@@ -32,6 +32,12 @@ export interface IntegrationCardData {
32
32
  sharedByAgentsCount: number;
33
33
  /** Whether this agent has a linked agent_tool for this integration */
34
34
  linkedToAgent: boolean;
35
+ /** Credential ID — set for connected/expired cards, undefined for "add new" */
36
+ credentialId?: number;
37
+ /** Account label (email or label from credential) */
38
+ accountLabel?: string;
39
+ /** Whether this is the "add new account" card */
40
+ isAddNew?: boolean;
35
41
  }
36
42
 
37
43
  // ---------------------------------------------------------------------------
@@ -42,6 +48,11 @@ export interface IntegrationCardData {
42
48
  * Cross-references the static integrations registry with live data
43
49
  * (tools, tool_credentials, agent_tools) to produce card state for each
44
50
  * integration entry.
51
+ *
52
+ * Returns:
53
+ * - One card per existing credential (connected/expired)
54
+ * - One "add new" card per integration type
55
+ * - Coming soon cards as before
45
56
  */
46
57
  export function useIntegrationState(
47
58
  config: GagentsHookConfig,
@@ -62,52 +73,88 @@ export function useIntegrationState(
62
73
  const tools: Tool[] = toolsData?.data ?? [];
63
74
  const agentTools: AgentTool[] = agentToolsData?.data ?? [];
64
75
 
65
- return INTEGRATIONS_REGISTRY.map((def) => {
76
+ const result: IntegrationCardData[] = [];
77
+
78
+ for (const def of INTEGRATIONS_REGISTRY) {
66
79
  // coming_soon short-circuit
67
80
  if (def.status === "coming_soon") {
68
- return {
81
+ result.push({
69
82
  definition: def,
70
83
  state: "coming_soon" as const,
71
84
  credential: null,
72
85
  tool: null,
73
86
  sharedByAgentsCount: 0,
74
87
  linkedToAgent: false,
75
- };
88
+ });
89
+ continue;
76
90
  }
77
91
 
78
92
  // Find tool record matching registry slug
79
93
  const matchedTool = tools.find((t) => t.slug === def.slug) ?? null;
80
94
 
81
- // Find credential for that tool
82
- const matchedCredential = matchedTool
83
- ? credentials.find((c) => c.id_tool === matchedTool.id) ?? null
84
- : null;
95
+ // Find ALL credentials for this integration
96
+ // Credentials can be linked via id_tool OR id_platform_integration
97
+ // We match by tool slug through the tool record
98
+ const matchedCredentials = matchedTool
99
+ ? credentials.filter((c) => c.id_tool === matchedTool.id)
100
+ : [];
101
+
102
+ // Also check credentials linked via platform_integration slug
103
+ // (platform_integrations use the same slug convention)
104
+ const piCredentials = credentials.filter(
105
+ (c) =>
106
+ c.id_platform_integration != null &&
107
+ !c.id_tool &&
108
+ // We can't directly match slug here since we don't have
109
+ // platform_integrations data, but credentials with
110
+ // id_platform_integration are for calendar integrations
111
+ // which match by the registry slug
112
+ matchedTool == null,
113
+ );
114
+
115
+ // Combine — prefer tool-based credentials, fallback to PI-based
116
+ const allCredentials =
117
+ matchedCredentials.length > 0 ? matchedCredentials : piCredentials;
85
118
 
86
119
  // Check if this agent has a linked agent_tool for this tool
87
120
  const linkedToAgent = matchedTool
88
121
  ? agentTools.some((at) => at.id_tool === matchedTool.id)
89
122
  : false;
90
123
 
91
- // Sharing indicator: credential exists at account level (available to all agents)
92
- // When a credential is account-scoped, any agent can use it — show as "shared"
93
- const sharedByAgentsCount = matchedCredential ? 1 : 0;
124
+ // Create one card per existing credential
125
+ for (const cred of allCredentials) {
126
+ const state: IntegrationCardState =
127
+ cred.status === "expired" ? "expired" : "connected";
94
128
 
95
- // Determine state
96
- let state: IntegrationCardState = "available";
97
- if (matchedCredential) {
98
- state =
99
- matchedCredential.status === "expired" ? "expired" : "connected";
129
+ // Derive account label from external_reference or label
130
+ const accountLabel =
131
+ cred.external_reference || cred.label || undefined;
132
+
133
+ result.push({
134
+ definition: def,
135
+ state,
136
+ credential: cred,
137
+ tool: matchedTool,
138
+ sharedByAgentsCount: 1,
139
+ linkedToAgent,
140
+ credentialId: cred.id,
141
+ accountLabel,
142
+ });
100
143
  }
101
144
 
102
- return {
145
+ // Always add an "add new account" card for this integration type
146
+ result.push({
103
147
  definition: def,
104
- state,
105
- credential: matchedCredential,
148
+ state: "available" as const,
149
+ credential: null,
106
150
  tool: matchedTool,
107
- sharedByAgentsCount,
108
- linkedToAgent,
109
- };
110
- });
151
+ sharedByAgentsCount: 0,
152
+ linkedToAgent: false,
153
+ isAddNew: true,
154
+ });
155
+ }
156
+
157
+ return result;
111
158
  }, [credentialsData, toolsData, agentToolsData]);
112
159
 
113
160
  return { cards, isLoading };
@@ -100,6 +100,8 @@ export interface ToolCredential {
100
100
  id: number;
101
101
  id_account: number;
102
102
  id_tool: number;
103
+ id_platform_integration: number | null;
104
+ external_reference: string | null;
103
105
  label: string;
104
106
  credentials_encrypted: string;
105
107
  expires_at: string | null;