@greatapps/greatagents-ui 0.3.3 → 0.3.4

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,162 @@
1
+ 'use client';
2
+
3
+ import type { IntegrationCardData, IntegrationCardState } from "../../hooks/use-integrations";
4
+ import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from "@greatapps/greatauth-ui/ui";
5
+ import {
6
+ CalendarSync,
7
+ Plug,
8
+ Settings,
9
+ RefreshCw,
10
+ Users,
11
+ Clock,
12
+ } from "lucide-react";
13
+ import type { LucideIcon } from "lucide-react";
14
+ import { cn } from "../../lib";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Icon mapping — extend as new integrations are added
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const ICON_MAP: Record<string, LucideIcon> = {
21
+ CalendarSync,
22
+ Plug,
23
+ Settings,
24
+ RefreshCw,
25
+ Users,
26
+ Clock,
27
+ };
28
+
29
+ function resolveIcon(name: string): LucideIcon {
30
+ return ICON_MAP[name] ?? Plug;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Badge helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface BadgeVariant {
38
+ label: string;
39
+ className: string;
40
+ }
41
+
42
+ const STATE_BADGES: Record<IntegrationCardState, BadgeVariant> = {
43
+ available: {
44
+ label: "Disponível",
45
+ className: "bg-muted text-muted-foreground",
46
+ },
47
+ connected: {
48
+ label: "Conectado",
49
+ className: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
50
+ },
51
+ expired: {
52
+ label: "Expirado",
53
+ className: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
54
+ },
55
+ coming_soon: {
56
+ label: "Em breve",
57
+ className: "bg-muted text-muted-foreground",
58
+ },
59
+ };
60
+
61
+ function getActionLabel(state: IntegrationCardState): string {
62
+ switch (state) {
63
+ case "available":
64
+ return "Conectar";
65
+ case "connected":
66
+ return "Configurar";
67
+ case "expired":
68
+ return "Reconectar";
69
+ default:
70
+ return "";
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Component
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export interface IntegrationCardProps {
79
+ card: IntegrationCardData;
80
+ onConnect: (card: IntegrationCardData) => void;
81
+ }
82
+
83
+ export function IntegrationCard({ card, onConnect }: IntegrationCardProps) {
84
+ const { definition, state, sharedByAgentsCount } = card;
85
+ const Icon = resolveIcon(definition.icon);
86
+ const badge = STATE_BADGES[state];
87
+ const actionLabel = getActionLabel(state);
88
+ const isComingSoon = state === "coming_soon";
89
+
90
+ return (
91
+ <div
92
+ className={cn(
93
+ "group relative flex flex-col gap-3 rounded-xl border bg-card p-5 transition-shadow",
94
+ isComingSoon
95
+ ? "opacity-60 cursor-default"
96
+ : "hover:shadow-md cursor-pointer",
97
+ )}
98
+ role="button"
99
+ tabIndex={isComingSoon ? -1 : 0}
100
+ aria-label={`${definition.name} — ${badge.label}`}
101
+ aria-disabled={isComingSoon}
102
+ onClick={() => !isComingSoon && onConnect(card)}
103
+ onKeyDown={(e) => {
104
+ if (!isComingSoon && (e.key === "Enter" || e.key === " ")) {
105
+ e.preventDefault();
106
+ onConnect(card);
107
+ }
108
+ }}
109
+ >
110
+ {/* Header row */}
111
+ <div className="flex items-start justify-between gap-2">
112
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
113
+ <Icon className="h-5 w-5" />
114
+ </div>
115
+ <Badge variant="outline" className={cn("text-xs", badge.className)}>
116
+ {badge.label}
117
+ </Badge>
118
+ </div>
119
+
120
+ {/* Name + description */}
121
+ <div className="space-y-1">
122
+ <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>
126
+ </div>
127
+
128
+ {/* Footer */}
129
+ <div className="mt-auto flex items-center justify-between gap-2 pt-1">
130
+ {sharedByAgentsCount > 0 ? (
131
+ <Tooltip>
132
+ <TooltipTrigger asChild>
133
+ <span className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
134
+ <Users className="h-3.5 w-3.5" />
135
+ Compartilhada
136
+ </span>
137
+ </TooltipTrigger>
138
+ <TooltipContent>
139
+ Esta credencial está disponível para todos os agentes da conta
140
+ </TooltipContent>
141
+ </Tooltip>
142
+ ) : (
143
+ <span />
144
+ )}
145
+
146
+ {!isComingSoon && (
147
+ <Button
148
+ variant={state === "expired" ? "destructive" : "outline"}
149
+ size="sm"
150
+ className="text-xs"
151
+ onClick={(e) => {
152
+ e.stopPropagation();
153
+ onConnect(card);
154
+ }}
155
+ >
156
+ {actionLabel}
157
+ </Button>
158
+ )}
159
+ </div>
160
+ </div>
161
+ );
162
+ }
@@ -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
+ }