@greatapps/greatagents-ui 0.3.3 → 0.3.5

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.
@@ -0,0 +1,537 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ Button,
11
+ } from "@greatapps/greatauth-ui/ui";
12
+ import { Loader2, ChevronLeft, ChevronRight, Check } from "lucide-react";
13
+ import { toast } from "sonner";
14
+
15
+ import type { GagentsHookConfig } from "../../hooks/types";
16
+ import type { IntegrationDefinition } from "../../data/integrations-registry";
17
+ import type {
18
+ WizardIntegrationMeta,
19
+ OAuthResult,
20
+ OAuthStatus,
21
+ WizardStep,
22
+ } from "./types";
23
+ import { InfoStep } from "./wizard-steps/info-step";
24
+ import { CredentialsStep } from "./wizard-steps/credentials-step";
25
+ import { ConfigStep, type ConfigOption } from "./wizard-steps/config-step";
26
+ import { ConfirmStep } from "./wizard-steps/confirm-step";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Constants
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const STEPS: WizardStep[] = ["info", "credentials", "config", "confirm"];
33
+
34
+ const STEP_LABELS: Record<WizardStep, string> = {
35
+ info: "Informação",
36
+ credentials: "Credenciais",
37
+ config: "Configuração",
38
+ confirm: "Confirmação",
39
+ };
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Props
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface IntegrationWizardProps {
46
+ open: boolean;
47
+ onOpenChange: (open: boolean) => void;
48
+ /** Base integration definition from registry */
49
+ integration: IntegrationDefinition;
50
+ /** Wizard-specific metadata (capabilities, requirements, etc.) */
51
+ meta: WizardIntegrationMeta;
52
+ agentId: string | number;
53
+ config: GagentsHookConfig;
54
+ onComplete: () => void;
55
+ /** gagents-r3-api base URL (used to build OAuth URLs) */
56
+ gagentsApiUrl: string;
57
+ /**
58
+ * Existing credential ID -- when set, the wizard opens in edit mode
59
+ * (step 2 shows "Reconectar" and step 3 is pre-filled).
60
+ */
61
+ existingCredentialId?: number;
62
+ /**
63
+ * Callback to load config options after OAuth completes.
64
+ * e.g. load Google Calendar list. Returns ConfigOption[].
65
+ */
66
+ loadConfigOptions?: (credentialId: number) => Promise<ConfigOption[]>;
67
+ /**
68
+ * Existing config value to pre-fill the config step in edit/reconnect mode.
69
+ */
70
+ existingConfigValue?: string;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Component
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export function IntegrationWizard({
78
+ open,
79
+ onOpenChange,
80
+ integration,
81
+ meta,
82
+ agentId: _agentId,
83
+ config,
84
+ onComplete,
85
+ gagentsApiUrl,
86
+ existingCredentialId,
87
+ loadConfigOptions,
88
+ existingConfigValue,
89
+ }: IntegrationWizardProps) {
90
+ const isReconnect = !!existingCredentialId;
91
+
92
+ // Step navigation
93
+ const [currentStep, setCurrentStep] = useState<WizardStep>("info");
94
+ const currentIndex = STEPS.indexOf(currentStep);
95
+
96
+ // OAuth state
97
+ const [oauthStatus, setOauthStatus] = useState<OAuthStatus>("idle");
98
+ const [oauthResult, setOauthResult] = useState<OAuthResult | null>(null);
99
+ const popupRef = useRef<Window | null>(null);
100
+ const popupPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
101
+
102
+ // API Key state
103
+ const [apiKey, setApiKey] = useState("");
104
+
105
+ // Config state
106
+ const [configOptions, setConfigOptions] = useState<ConfigOption[]>([]);
107
+ const [configLoading, setConfigLoading] = useState(false);
108
+ const [selectedConfigValue, setSelectedConfigValue] = useState("");
109
+
110
+ // Confirm state
111
+ const [enableOnComplete, setEnableOnComplete] = useState(true);
112
+ const [isSubmitting, setIsSubmitting] = useState(false);
113
+
114
+ // Clean up popup poll interval on unmount
115
+ useEffect(() => {
116
+ return () => {
117
+ if (popupPollRef.current) {
118
+ clearInterval(popupPollRef.current);
119
+ }
120
+ };
121
+ }, []);
122
+
123
+ // Reset state when dialog opens/closes
124
+ useEffect(() => {
125
+ if (open) {
126
+ setCurrentStep("info");
127
+ setOauthStatus("idle");
128
+ setOauthResult(null);
129
+ setApiKey("");
130
+ setConfigOptions([]);
131
+ setConfigLoading(false);
132
+ setSelectedConfigValue(existingConfigValue ?? "");
133
+ setEnableOnComplete(true);
134
+ setIsSubmitting(false);
135
+ } else {
136
+ // Close any lingering popup
137
+ if (popupRef.current && !popupRef.current.closed) {
138
+ popupRef.current.close();
139
+ }
140
+ // Clear popup poll interval
141
+ if (popupPollRef.current) {
142
+ clearInterval(popupPollRef.current);
143
+ popupPollRef.current = null;
144
+ }
145
+ }
146
+ }, [open]);
147
+
148
+ // -----------------------------------------------------------------------
149
+ // OAuth popup message listener
150
+ // -----------------------------------------------------------------------
151
+
152
+ const handleOAuthMessage = useCallback(
153
+ (event: MessageEvent) => {
154
+ // Validate origin against gagents API URL
155
+ try {
156
+ if (event.origin !== new URL(gagentsApiUrl).origin) return;
157
+ } catch {
158
+ return;
159
+ }
160
+
161
+ // Only accept messages from our popup -- check for known data shape
162
+ if (!event.data || typeof event.data !== "object") return;
163
+ const msg = event.data as {
164
+ type?: string;
165
+ success?: boolean;
166
+ email?: string;
167
+ error?: string;
168
+ credentialId?: number;
169
+ };
170
+
171
+ if (msg.type !== "oauth-callback") return;
172
+
173
+ if (msg.success) {
174
+ setOauthStatus("success");
175
+ setOauthResult({
176
+ success: true,
177
+ email: msg.email,
178
+ credentialId: msg.credentialId,
179
+ });
180
+
181
+ // Auto-load config options if available
182
+ const credId = msg.credentialId || existingCredentialId;
183
+ if (credId && loadConfigOptions && meta.hasConfigStep) {
184
+ setConfigLoading(true);
185
+ loadConfigOptions(credId)
186
+ .then((opts) => {
187
+ setConfigOptions(opts);
188
+ if (opts.length === 1) {
189
+ setSelectedConfigValue(opts[0].id);
190
+ }
191
+ })
192
+ .catch(() => setConfigOptions([]))
193
+ .finally(() => setConfigLoading(false));
194
+ }
195
+
196
+ // Auto-advance to next step after a brief delay
197
+ setTimeout(() => {
198
+ setCurrentStep(meta.hasConfigStep ? "config" : "confirm");
199
+ }, 1200);
200
+ } else {
201
+ setOauthStatus("error");
202
+ setOauthResult({
203
+ success: false,
204
+ error: msg.error || "Falha na autorização",
205
+ });
206
+ }
207
+ },
208
+ [gagentsApiUrl, existingCredentialId, meta.hasConfigStep, loadConfigOptions],
209
+ );
210
+
211
+ useEffect(() => {
212
+ if (!open) return;
213
+ window.addEventListener("message", handleOAuthMessage);
214
+ return () => window.removeEventListener("message", handleOAuthMessage);
215
+ }, [open, handleOAuthMessage]);
216
+
217
+ // -----------------------------------------------------------------------
218
+ // OAuth start
219
+ // -----------------------------------------------------------------------
220
+
221
+ function startOAuth() {
222
+ const { language = "pt-br", idWl = 1, accountId } = config;
223
+
224
+ // Build OAuth authorize URL -- the backend already handles the full flow
225
+ const redirectUri = `${window.location.origin}/oauth/callback`;
226
+ const url = new URL(
227
+ `${gagentsApiUrl}/v1/${language}/${idWl}/accounts/${accountId}/oauth/authorize/${integration.slug}`,
228
+ );
229
+ url.searchParams.set("redirect_uri", redirectUri);
230
+
231
+ setOauthStatus("waiting");
232
+
233
+ // Open popup
234
+ const popup = window.open(
235
+ url.toString(),
236
+ "oauth-popup",
237
+ "width=500,height=600,scrollbars=yes,resizable=yes",
238
+ );
239
+ popupRef.current = popup;
240
+
241
+ // Poll for popup closed without completing
242
+ if (popup) {
243
+ // Clear any previous poll interval
244
+ if (popupPollRef.current) {
245
+ clearInterval(popupPollRef.current);
246
+ }
247
+ popupPollRef.current = setInterval(() => {
248
+ if (popup.closed) {
249
+ if (popupPollRef.current) {
250
+ clearInterval(popupPollRef.current);
251
+ popupPollRef.current = null;
252
+ }
253
+ // Only set error if we're still waiting (no success message received)
254
+ setOauthStatus((prev) =>
255
+ prev === "waiting" ? "error" : prev,
256
+ );
257
+ setOauthResult((prev) =>
258
+ prev === null
259
+ ? { success: false, error: "Janela fechada antes de concluir" }
260
+ : prev,
261
+ );
262
+ }
263
+ }, 500);
264
+ }
265
+ }
266
+
267
+ // -----------------------------------------------------------------------
268
+ // Navigation
269
+ // -----------------------------------------------------------------------
270
+
271
+ function canAdvance(): boolean {
272
+ switch (currentStep) {
273
+ case "info":
274
+ return true;
275
+ case "credentials":
276
+ if (integration.authType === "oauth2") {
277
+ return oauthStatus === "success";
278
+ }
279
+ return apiKey.trim().length > 0;
280
+ case "config":
281
+ // Config step is optional -- can always advance
282
+ return true;
283
+ case "confirm":
284
+ return true;
285
+ default:
286
+ return false;
287
+ }
288
+ }
289
+
290
+ function goNext() {
291
+ if (!canAdvance()) return;
292
+
293
+ // Skip config step if not needed
294
+ if (currentStep === "credentials" && !meta.hasConfigStep) {
295
+ setCurrentStep("confirm");
296
+ return;
297
+ }
298
+
299
+ const nextIndex = currentIndex + 1;
300
+ if (nextIndex < STEPS.length) {
301
+ setCurrentStep(STEPS[nextIndex]);
302
+ }
303
+ }
304
+
305
+ function goPrev() {
306
+ // Skip config step backwards if not needed
307
+ if (currentStep === "confirm" && !meta.hasConfigStep) {
308
+ setCurrentStep("credentials");
309
+ return;
310
+ }
311
+
312
+ const prevIndex = currentIndex - 1;
313
+ if (prevIndex >= 0) {
314
+ setCurrentStep(STEPS[prevIndex]);
315
+ }
316
+ }
317
+
318
+ // -----------------------------------------------------------------------
319
+ // Complete
320
+ // -----------------------------------------------------------------------
321
+
322
+ async function handleComplete() {
323
+ setIsSubmitting(true);
324
+ try {
325
+ // The actual credential creation happened during OAuth callback
326
+ // or will happen via API key submission. Here we just finalize.
327
+ //
328
+ // For OAuth, the credential was already created by the backend callback.
329
+ // The parent component handles any additional linking (agent_tool creation)
330
+ // via the onComplete callback.
331
+
332
+ onComplete();
333
+ onOpenChange(false);
334
+ toast.success(
335
+ `${integration.name} ${isReconnect ? "reconectado" : "configurado"} com sucesso!`,
336
+ );
337
+ } catch {
338
+ toast.error("Erro ao finalizar configuração");
339
+ } finally {
340
+ setIsSubmitting(false);
341
+ }
342
+ }
343
+
344
+ // -----------------------------------------------------------------------
345
+ // Render
346
+ // -----------------------------------------------------------------------
347
+
348
+ const selectedConfigOption =
349
+ configOptions.find((o) => o.id === selectedConfigValue) || null;
350
+
351
+ const isLastStep = currentStep === "confirm";
352
+
353
+ // Effective steps (may skip config)
354
+ const effectiveSteps = meta.hasConfigStep
355
+ ? STEPS
356
+ : STEPS.filter((s) => s !== "config");
357
+
358
+ return (
359
+ <Dialog open={open} onOpenChange={onOpenChange}>
360
+ <DialogContent className="sm:max-w-lg">
361
+ <DialogHeader>
362
+ <DialogTitle>{integration.name}</DialogTitle>
363
+ </DialogHeader>
364
+
365
+ {/* Step indicator */}
366
+ <StepIndicator steps={effectiveSteps} currentStep={currentStep} />
367
+
368
+ {/* Step content */}
369
+ <div className="min-h-[280px] py-2">
370
+ {currentStep === "info" && (
371
+ <InfoStep
372
+ integration={integration}
373
+ meta={meta}
374
+ />
375
+ )}
376
+ {currentStep === "credentials" && (
377
+ <CredentialsStep
378
+ integration={integration}
379
+ meta={meta}
380
+ oauthStatus={oauthStatus}
381
+ oauthResult={oauthResult}
382
+ apiKey={apiKey}
383
+ onApiKeyChange={setApiKey}
384
+ onStartOAuth={startOAuth}
385
+ isReconnect={isReconnect}
386
+ />
387
+ )}
388
+ {currentStep === "config" && (
389
+ <ConfigStep
390
+ integration={integration}
391
+ options={configOptions}
392
+ isLoading={configLoading}
393
+ selectedValue={selectedConfigValue}
394
+ onValueChange={setSelectedConfigValue}
395
+ />
396
+ )}
397
+ {currentStep === "confirm" && (
398
+ <ConfirmStep
399
+ integration={integration}
400
+ oauthResult={oauthResult}
401
+ selectedConfigOption={selectedConfigOption}
402
+ enableOnComplete={enableOnComplete}
403
+ onEnableChange={setEnableOnComplete}
404
+ />
405
+ )}
406
+ </div>
407
+
408
+ {/* Footer */}
409
+ <DialogFooter className="flex-row justify-between sm:justify-between">
410
+ <div>
411
+ {currentStep === "info" ? (
412
+ <Button
413
+ type="button"
414
+ variant="outline"
415
+ onClick={() => onOpenChange(false)}
416
+ >
417
+ Cancelar
418
+ </Button>
419
+ ) : (
420
+ <Button
421
+ type="button"
422
+ variant="outline"
423
+ onClick={goPrev}
424
+ className="gap-1"
425
+ >
426
+ <ChevronLeft aria-hidden="true" className="h-4 w-4" />
427
+ Voltar
428
+ </Button>
429
+ )}
430
+ </div>
431
+ <div>
432
+ {isLastStep ? (
433
+ <Button
434
+ type="button"
435
+ onClick={handleComplete}
436
+ disabled={isSubmitting}
437
+ className="gap-1"
438
+ >
439
+ {isSubmitting ? (
440
+ <Loader2
441
+ aria-hidden="true"
442
+ className="h-4 w-4 animate-spin"
443
+ />
444
+ ) : (
445
+ <Check aria-hidden="true" className="h-4 w-4" />
446
+ )}
447
+ Concluir
448
+ </Button>
449
+ ) : (
450
+ <Button
451
+ type="button"
452
+ onClick={goNext}
453
+ disabled={!canAdvance()}
454
+ className="gap-1"
455
+ >
456
+ Continuar
457
+ <ChevronRight aria-hidden="true" className="h-4 w-4" />
458
+ </Button>
459
+ )}
460
+ </div>
461
+ </DialogFooter>
462
+ </DialogContent>
463
+ </Dialog>
464
+ );
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Step Indicator
469
+ // ---------------------------------------------------------------------------
470
+
471
+ function StepIndicator({
472
+ steps,
473
+ currentStep,
474
+ }: {
475
+ steps: WizardStep[];
476
+ currentStep: WizardStep;
477
+ }) {
478
+ const currentIndex = steps.indexOf(currentStep);
479
+
480
+ return (
481
+ <div
482
+ className="flex items-center justify-center gap-1 py-2"
483
+ role="list"
484
+ aria-label="Passos do assistente"
485
+ >
486
+ {steps.map((step, i) => {
487
+ const isCompleted = i < currentIndex;
488
+ const isCurrent = step === currentStep;
489
+
490
+ return (
491
+ <div key={step} className="flex items-center" role="listitem">
492
+ {/* Circle */}
493
+ <div
494
+ className={`
495
+ flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium
496
+ transition-colors duration-200
497
+ ${
498
+ isCurrent
499
+ ? "bg-primary text-primary-foreground"
500
+ : isCompleted
501
+ ? "bg-green-600 text-white"
502
+ : "bg-muted text-muted-foreground"
503
+ }
504
+ `}
505
+ aria-current={isCurrent ? "step" : undefined}
506
+ aria-label={`${STEP_LABELS[step]}${isCompleted ? " (concluído)" : isCurrent ? " (atual)" : ""}`}
507
+ >
508
+ {isCompleted ? (
509
+ <Check aria-hidden="true" className="h-3.5 w-3.5" />
510
+ ) : (
511
+ i + 1
512
+ )}
513
+ </div>
514
+ {/* Label */}
515
+ <span
516
+ className={`
517
+ ml-1.5 hidden text-xs sm:inline
518
+ ${isCurrent ? "font-medium text-foreground" : "text-muted-foreground"}
519
+ `}
520
+ >
521
+ {STEP_LABELS[step]}
522
+ </span>
523
+ {/* Connector line */}
524
+ {i < steps.length - 1 && (
525
+ <div
526
+ className={`
527
+ mx-2 h-px w-6
528
+ ${i < currentIndex ? "bg-green-600" : "bg-border"}
529
+ `}
530
+ />
531
+ )}
532
+ </div>
533
+ );
534
+ })}
535
+ </div>
536
+ );
537
+ }
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import type { GagentsHookConfig } from "../../hooks/types";
4
+ import { useIntegrationState, type IntegrationCardData } from "../../hooks/use-integrations";
5
+ import { IntegrationCard } from "./integration-card";
6
+ import { Plug, Loader2 } from "lucide-react";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Props
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface IntegrationsTabProps {
13
+ config: GagentsHookConfig;
14
+ agentId: number | null;
15
+ /** Called when user clicks a card action (connect / configure / reconnect).
16
+ * The consuming app wires this to the wizard (Story 18.9). */
17
+ onConnect: (card: IntegrationCardData) => void;
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Component
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export function IntegrationsTab({
25
+ config,
26
+ agentId,
27
+ onConnect,
28
+ }: IntegrationsTabProps) {
29
+ const { cards, isLoading } = useIntegrationState(config, agentId);
30
+
31
+ // Loading state
32
+ if (isLoading) {
33
+ return (
34
+ <div className="flex items-center justify-center py-16">
35
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
36
+ </div>
37
+ );
38
+ }
39
+
40
+ // Empty state
41
+ if (cards.length === 0) {
42
+ return (
43
+ <div className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
44
+ <Plug className="h-10 w-10" />
45
+ <p className="text-sm">Nenhuma integração disponível</p>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ 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
+ ))}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Types for the Integration Wizard flow.
3
+ *
4
+ * The wizard uses the base IntegrationDefinition from the integrations registry
5
+ * and extends it with wizard-specific metadata via WizardIntegrationMeta.
6
+ */
7
+
8
+ import type { IntegrationDefinition } from "../../data/integrations-registry";
9
+
10
+ // Re-export for convenience
11
+ export type { IntegrationDefinition };
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Wizard-specific metadata (passed alongside the registry definition)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface IntegrationCapability {
18
+ label: string;
19
+ description?: string;
20
+ }
21
+
22
+ export interface WizardIntegrationMeta {
23
+ /** Icon as React node for the wizard header */
24
+ icon?: React.ReactNode;
25
+ /** Provider label for OAuth button (e.g. "Google") */
26
+ providerLabel?: string;
27
+ /** What this integration can do */
28
+ capabilities: IntegrationCapability[];
29
+ /** Required permissions / prerequisites */
30
+ requirements: string[];
31
+ /** Whether this integration has a config step */
32
+ hasConfigStep: boolean;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Wizard state types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type WizardStep = "info" | "credentials" | "config" | "confirm";
40
+
41
+ export type OAuthStatus = "idle" | "waiting" | "success" | "error";
42
+
43
+ export interface OAuthResult {
44
+ success: boolean;
45
+ email?: string;
46
+ error?: string;
47
+ credentialId?: number;
48
+ }