@polpo-ai/dashboard 0.1.0

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 (135) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +44 -0
  3. package/app/(dashboard)/error.tsx +47 -0
  4. package/app/(dashboard)/layout.tsx +41 -0
  5. package/app/(dashboard)/loading.tsx +30 -0
  6. package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
  7. package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
  8. package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
  9. package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
  10. package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
  11. package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
  12. package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
  13. package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
  14. package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
  15. package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
  16. package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
  17. package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
  18. package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
  19. package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
  20. package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
  21. package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
  22. package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
  23. package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
  24. package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
  25. package/app/(dashboard)/projects/[id]/page.tsx +68 -0
  26. package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
  27. package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
  28. package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
  29. package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
  30. package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
  31. package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
  32. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
  33. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
  34. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
  35. package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
  36. package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
  37. package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
  38. package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
  39. package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
  40. package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
  41. package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
  42. package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
  43. package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
  44. package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
  45. package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
  46. package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
  47. package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
  48. package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
  49. package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
  50. package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
  51. package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
  52. package/app/(dashboard)/projects/[id]/view.tsx +421 -0
  53. package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
  54. package/app/(dashboard)/projects/projects-list.tsx +126 -0
  55. package/app/(playground)/error.tsx +42 -0
  56. package/app/(playground)/layout.tsx +27 -0
  57. package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
  58. package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
  59. package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
  60. package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
  61. package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
  62. package/app/favicon.ico +0 -0
  63. package/app/globals.css +385 -0
  64. package/app/icon.svg +6 -0
  65. package/app/layout.tsx +39 -0
  66. package/app/page.tsx +11 -0
  67. package/components/ai-elements/code-block.tsx +562 -0
  68. package/components/dashboard/agent-capabilities.tsx +326 -0
  69. package/components/dashboard/agent-identity-header.tsx +50 -0
  70. package/components/dashboard/agent-model-picker.tsx +195 -0
  71. package/components/dashboard/agent-model-selector.tsx +121 -0
  72. package/components/dashboard/agent-picker-card.tsx +46 -0
  73. package/components/dashboard/agent-studio.tsx +242 -0
  74. package/components/dashboard/agents-table.tsx +48 -0
  75. package/components/dashboard/breadcrumb.tsx +70 -0
  76. package/components/dashboard/builder-chat.tsx +418 -0
  77. package/components/dashboard/chat-shell.tsx +40 -0
  78. package/components/dashboard/client-picker.tsx +63 -0
  79. package/components/dashboard/command-snippet.tsx +77 -0
  80. package/components/dashboard/connect-dialog.tsx +680 -0
  81. package/components/dashboard/copy-button.tsx +45 -0
  82. package/components/dashboard/copy-card.tsx +48 -0
  83. package/components/dashboard/file-browser.tsx +161 -0
  84. package/components/dashboard/hint.tsx +22 -0
  85. package/components/dashboard/inference-mode.tsx +15 -0
  86. package/components/dashboard/manual-refresh-button.tsx +52 -0
  87. package/components/dashboard/markdown.tsx +31 -0
  88. package/components/dashboard/mcp-install-panel.tsx +196 -0
  89. package/components/dashboard/mission-graph.tsx +262 -0
  90. package/components/dashboard/nav-tabs.tsx +86 -0
  91. package/components/dashboard/polpo-chat.tsx +468 -0
  92. package/components/dashboard/project-copilot.tsx +253 -0
  93. package/components/dashboard/sdk-snippet-panel.tsx +194 -0
  94. package/components/dashboard/section-header.tsx +37 -0
  95. package/components/dashboard/settings-form.tsx +2477 -0
  96. package/components/dashboard/sidebar.tsx +385 -0
  97. package/components/dashboard/skeletons.tsx +1054 -0
  98. package/components/dashboard/skills-install-wizard.tsx +273 -0
  99. package/components/dashboard/swarm-runs.tsx +316 -0
  100. package/components/dashboard/tab-toggle.tsx +35 -0
  101. package/components/dashboard/task-studio.tsx +224 -0
  102. package/components/dashboard/top-header.tsx +203 -0
  103. package/components/dashboard/webhook-deliveries.tsx +234 -0
  104. package/components/icons/coding-agents.tsx +151 -0
  105. package/components/json-ld.tsx +8 -0
  106. package/components/providers.tsx +25 -0
  107. package/components/ui/badge.tsx +52 -0
  108. package/components/ui/button.tsx +60 -0
  109. package/components/ui/card.tsx +103 -0
  110. package/components/ui/chart.tsx +356 -0
  111. package/components/ui/command.tsx +196 -0
  112. package/components/ui/dialog.tsx +157 -0
  113. package/components/ui/input-group.tsx +158 -0
  114. package/components/ui/input.tsx +20 -0
  115. package/components/ui/multi-select.tsx +161 -0
  116. package/components/ui/popover.tsx +90 -0
  117. package/components/ui/select.tsx +201 -0
  118. package/components/ui/separator.tsx +25 -0
  119. package/components/ui/sheet.tsx +135 -0
  120. package/components/ui/skeleton.tsx +13 -0
  121. package/components/ui/tabs.tsx +56 -0
  122. package/components/ui/textarea.tsx +18 -0
  123. package/components/ui/tooltip.tsx +66 -0
  124. package/hooks/use-desktop-sidebar.tsx +50 -0
  125. package/hooks/use-mobile-sidebar.tsx +37 -0
  126. package/index.ts +14 -0
  127. package/lib/api.ts +194 -0
  128. package/lib/builder-context.ts +60 -0
  129. package/lib/data-client.ts +68 -0
  130. package/lib/get-query-client.ts +25 -0
  131. package/lib/polpo-client.tsx +49 -0
  132. package/lib/tool-catalog.ts +234 -0
  133. package/lib/use-event-catalog.ts +28 -0
  134. package/lib/utils.ts +6 -0
  135. package/package.json +99 -0
@@ -0,0 +1,2477 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
6
+ import {
7
+ Plus,
8
+ Trash2,
9
+ Loader2,
10
+ RefreshCw,
11
+ AlertTriangle,
12
+ Shield,
13
+ Check,
14
+ Copy,
15
+ ExternalLink,
16
+ Plug,
17
+ FileText,
18
+ CheckCircle2,
19
+ ChevronDown,
20
+ ChevronRight,
21
+ Minus,
22
+ Pencil,
23
+ Eye,
24
+ } from "lucide-react";
25
+ import { fetchDataPlane, mutateDataPlane } from "#/lib/data-client";
26
+ import { fetchControlPlane } from "#/lib/data-client";
27
+ import type { ProjectSettings, ProjectGatewaySettings } from "#/lib/api";
28
+ import {
29
+ Dialog,
30
+ DialogContent,
31
+ DialogHeader,
32
+ DialogTitle,
33
+ DialogDescription,
34
+ DialogFooter,
35
+ DialogClose,
36
+ } from "#/components/ui/dialog";
37
+ import { CopyCard } from "#/components/dashboard/copy-card";
38
+ import { useEventCatalog } from "#/lib/use-event-catalog";
39
+ import { WebhookDeliveries } from "#/components/dashboard/webhook-deliveries";
40
+ import { InferenceModeRadio, type InferenceMode } from "#/components/dashboard/inference-mode";
41
+ import type { ByokEntry } from "#/lib/api";
42
+
43
+ const SALES_EMAIL = "hello@polpo.sh";
44
+
45
+ /** Providers selectable for request-scoped BYOK. Mirrors PROVIDER_ENV_MAP
46
+ * in @polpo-ai/core (the supported set the server validates against). */
47
+ const BYOK_PROVIDERS = [
48
+ { value: "openai", label: "OpenAI" },
49
+ { value: "anthropic", label: "Anthropic" },
50
+ { value: "google", label: "Google (Gemini)" },
51
+ { value: "xai", label: "xAI (Grok)" },
52
+ { value: "groq", label: "Groq" },
53
+ { value: "openrouter", label: "OpenRouter" },
54
+ { value: "cerebras", label: "Cerebras" },
55
+ { value: "mistral", label: "Mistral" },
56
+ { value: "deepseek", label: "DeepSeek" },
57
+ ];
58
+
59
+ interface Webhook {
60
+ id: string;
61
+ url: string;
62
+ events: string[];
63
+ created_at: string;
64
+ }
65
+
66
+
67
+ interface SettingsFormProps {
68
+ projectId: string;
69
+ projectName: string;
70
+ projectSlug: string;
71
+ apiEndpoint: string;
72
+ initialSettings?: ProjectSettings | null;
73
+ /**
74
+ * Server-fetched Autumn integration status — passed to <IntegrationsTab>
75
+ * as TanStack Query `initialData` so the card renders the connection
76
+ * state instantly instead of flashing a "Loading status…" spinner.
77
+ */
78
+ initialAutumnStatus?: AutumnStatusResponse | null;
79
+ }
80
+
81
+ type TabId = "general" | "gateway" | "integrations" | "webhooks";
82
+
83
+ const TABS: { id: TabId; label: string }[] = [
84
+ { id: "general", label: "General" },
85
+ { id: "gateway", label: "AI Gateway" },
86
+ { id: "integrations", label: "Integrations" },
87
+ { id: "webhooks", label: "Webhooks" },
88
+ ];
89
+
90
+ export function SettingsForm({ projectId, projectName, projectSlug, apiEndpoint, initialSettings, initialAutumnStatus }: SettingsFormProps) {
91
+ const [active, setActive] = useState<TabId>("general");
92
+
93
+ return (
94
+ <div className="max-w-2xl">
95
+ <h1 className="text-xl font-extrabold tracking-tight">Project Settings</h1>
96
+ <p className="mt-1 text-sm text-muted-foreground">
97
+ Configure this project's general info, LLM gateway, and webhooks.
98
+ </p>
99
+
100
+ <div className="mt-8 flex gap-1 border-b border-border overflow-x-auto scrollbar-none">
101
+ {TABS.map((tab) => (
102
+ <button
103
+ key={tab.id}
104
+ type="button"
105
+ onClick={() => setActive(tab.id)}
106
+ data-testid={`settings-tab-${tab.id}`}
107
+ className={`relative px-3 py-2 text-sm transition-colors whitespace-nowrap ${
108
+ active === tab.id
109
+ ? "text-foreground font-medium"
110
+ : "text-muted-foreground hover:text-foreground"
111
+ }`}
112
+ >
113
+ {tab.label}
114
+ {active === tab.id && (
115
+ <span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
116
+ )}
117
+ </button>
118
+ ))}
119
+ </div>
120
+
121
+ <div className="mt-8">
122
+ {active === "general" && (
123
+ <GeneralTab
124
+ projectId={projectId}
125
+ projectName={projectName}
126
+ projectSlug={projectSlug}
127
+ apiEndpoint={apiEndpoint}
128
+ />
129
+ )}
130
+ {active === "gateway" && (
131
+ <GatewayTab projectId={projectId} initialSettings={initialSettings} />
132
+ )}
133
+ {active === "integrations" && (
134
+ <IntegrationsTab
135
+ projectId={projectId}
136
+ initialStatus={initialAutumnStatus}
137
+ />
138
+ )}
139
+ {active === "webhooks" && <WebhooksTab projectId={projectId} />}
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ /* ── General Tab ──────────────────────────────────────────── */
146
+
147
+ function GeneralTab({ projectId, projectName, projectSlug, apiEndpoint }: {
148
+ projectId: string;
149
+ projectName: string;
150
+ projectSlug: string;
151
+ apiEndpoint: string;
152
+ }) {
153
+ const router = useRouter();
154
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
155
+ const [confirmName, setConfirmName] = useState("");
156
+ const [deleteError, setDeleteError] = useState<string | null>(null);
157
+ const [nameInput, setNameInput] = useState(projectName);
158
+ const [renameError, setRenameError] = useState<string | null>(null);
159
+ const [renameSaved, setRenameSaved] = useState(false);
160
+
161
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
162
+
163
+ const renameMutation = useMutation({
164
+ mutationFn: async (name: string) => {
165
+ const res = await fetch(`${API_URL}/v1/projects/${projectId}`, {
166
+ method: "PATCH",
167
+ credentials: "include",
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({ name }),
170
+ });
171
+ if (!res.ok) {
172
+ const data = await res.json().catch(() => ({}));
173
+ throw new Error(data?.error ?? `Failed (${res.status})`);
174
+ }
175
+ return res.json();
176
+ },
177
+ onSuccess: () => {
178
+ setRenameError(null);
179
+ setRenameSaved(true);
180
+ setTimeout(() => setRenameSaved(false), 2000);
181
+ router.refresh();
182
+ },
183
+ onError: (err: Error) => {
184
+ setRenameError(err.message);
185
+ },
186
+ });
187
+
188
+ const nameChanged = nameInput.trim() !== projectName && nameInput.trim().length > 0;
189
+
190
+ const deleteMutation = useMutation({
191
+ mutationFn: async () => {
192
+ const res = await fetch(`${API_URL}/v1/projects/${projectId}`, {
193
+ method: "DELETE",
194
+ credentials: "include",
195
+ headers: { "Content-Type": "application/json" },
196
+ });
197
+ if (!res.ok) {
198
+ const data = await res.json().catch(() => ({}));
199
+ throw new Error(data.error ?? `Error ${res.status}`);
200
+ }
201
+ return res.json();
202
+ },
203
+ onSuccess: () => {
204
+ router.push("/projects");
205
+ },
206
+ onError: (err: Error) => {
207
+ setDeleteError(err.message);
208
+ },
209
+ });
210
+
211
+ return (
212
+ <section>
213
+ <h2 className="text-lg font-semibold tracking-tight">Project details</h2>
214
+ <div className="mt-4 space-y-4">
215
+ <div>
216
+ <label className="block text-xs font-medium text-muted-foreground mb-1.5">
217
+ Project name
218
+ </label>
219
+ <div className="flex items-stretch gap-2">
220
+ <input
221
+ type="text"
222
+ value={nameInput}
223
+ onChange={(e) => setNameInput(e.target.value)}
224
+ className="flex-1 border border-border bg-transparent px-3 py-2.5 text-sm focus:border-foreground/30 focus:outline-none transition-colors"
225
+ />
226
+ <button
227
+ type="button"
228
+ onClick={() => renameMutation.mutate(nameInput.trim())}
229
+ disabled={!nameChanged || renameMutation.isPending}
230
+ className="rounded border border-border bg-foreground/5 px-4 py-2 text-sm font-medium text-foreground hover:bg-foreground hover:text-background transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-foreground/5 disabled:hover:text-foreground"
231
+ >
232
+ {renameMutation.isPending ? "Saving…" : renameSaved ? "Saved" : "Save"}
233
+ </button>
234
+ </div>
235
+ {renameError && (
236
+ <p className="mt-2 text-xs text-destructive">{renameError}</p>
237
+ )}
238
+ </div>
239
+ <div>
240
+ <CopyCard label="Project ID" value={projectId} />
241
+ <p className="mt-1 text-[11px] text-muted-foreground">
242
+ Use this in the CLI:{" "}
243
+ <span className="font-mono">polpo --project-id &lt;ID&gt;</span>
244
+ </p>
245
+ </div>
246
+ <div>
247
+ <CopyCard label="API endpoint" value={apiEndpoint} />
248
+ <p className="mt-1 text-[11px] text-muted-foreground">
249
+ Base URL for your project's data plane. Pair it with a project API
250
+ key.
251
+ </p>
252
+ </div>
253
+ </div>
254
+ {/* Danger zone */}
255
+ <div className="mt-16 border border-destructive/20 p-6">
256
+ <h3 className="text-sm font-medium text-destructive">Danger zone</h3>
257
+ <p className="mt-1 text-xs text-muted-foreground">
258
+ Permanently remove this project, all agents, data, and API keys. This cannot be undone.
259
+ </p>
260
+
261
+ {!showDeleteConfirm ? (
262
+ <button
263
+ onClick={() => setShowDeleteConfirm(true)}
264
+ className="mt-4 border border-destructive/30 px-4 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
265
+ >
266
+ Delete project
267
+ </button>
268
+ ) : (
269
+ <div className="mt-4 space-y-3">
270
+ <div className="flex items-start gap-2 rounded border border-destructive/20 bg-destructive/5 p-3">
271
+ <AlertTriangle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
272
+ <p className="text-xs text-destructive">
273
+ Type <span className="font-mono font-bold">{projectName}</span> to confirm deletion.
274
+ </p>
275
+ </div>
276
+ <input
277
+ type="text"
278
+ value={confirmName}
279
+ onChange={(e) => setConfirmName(e.target.value)}
280
+ placeholder={projectName}
281
+ className="w-full border border-destructive/30 bg-transparent px-3 py-2 text-sm font-mono focus:border-destructive focus:outline-none transition-colors"
282
+ autoFocus
283
+ />
284
+ {deleteError && (
285
+ <p className="text-xs text-destructive">{deleteError}</p>
286
+ )}
287
+ <div className="flex items-center gap-3">
288
+ <button
289
+ onClick={() => deleteMutation.mutate()}
290
+ disabled={confirmName !== projectName || deleteMutation.isPending}
291
+ className="inline-flex items-center gap-2 bg-destructive text-destructive-foreground px-4 py-1.5 text-xs font-medium transition-all hover:opacity-90 disabled:opacity-30"
292
+ >
293
+ {deleteMutation.isPending ? (
294
+ <Loader2 className="h-3 w-3 animate-spin" />
295
+ ) : (
296
+ "Delete permanently"
297
+ )}
298
+ </button>
299
+ <button
300
+ onClick={() => { setShowDeleteConfirm(false); setConfirmName(""); setDeleteError(null); }}
301
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
302
+ >
303
+ Cancel
304
+ </button>
305
+ </div>
306
+ </div>
307
+ )}
308
+ </div>
309
+ </section>
310
+ );
311
+ }
312
+
313
+ /* ── LLM Gateway Tab ─────────────────────────────────────── */
314
+
315
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
316
+
317
+ function GatewayTab({ projectId, initialSettings }: { projectId: string; initialSettings?: ProjectSettings | null }) {
318
+ const existingGateway = initialSettings?.gateway;
319
+
320
+ // Provider keys (request-scoped BYOK) — fetched on mount.
321
+ const [keys, setKeys] = useState<ByokEntry[]>([]);
322
+ const [keysLoaded, setKeysLoaded] = useState(false);
323
+ useEffect(() => {
324
+ let cancelled = false;
325
+ fetch(`${API_URL}/v1/byok/${projectId}`, { credentials: "include" })
326
+ .then((r) => (r.ok ? r.json() : { data: [] }))
327
+ .then((d) => { if (!cancelled) setKeys(d.data ?? []); })
328
+ .catch(() => {})
329
+ .finally(() => { if (!cancelled) setKeysLoaded(true); });
330
+ return () => { cancelled = true; };
331
+ }, [projectId]);
332
+
333
+ const providerKeys = keys.filter((k) => k.provider !== "gateway");
334
+ const hasCustomGateway = !!existingGateway?.url;
335
+
336
+ // Derived current mode → initial view.
337
+ function derive(): InferenceMode {
338
+ if (hasCustomGateway) return "gateway";
339
+ if (providerKeys.length > 0) return "byok";
340
+ return "managed";
341
+ }
342
+ const [viewMode, setViewMode] = useState<InferenceMode>(derive);
343
+ // Re-sync once keys arrive (so a project with keys lands on BYOK).
344
+ useEffect(() => {
345
+ if (keysLoaded) setViewMode(derive());
346
+ // eslint-disable-next-line react-hooks/exhaustive-deps
347
+ }, [keysLoaded]);
348
+
349
+ // Custom gateway is Enterprise-gated: self-serve only when already
350
+ // configured (existing users keep editing); new projects see "Contact us".
351
+ const customGatewayEnabled = hasCustomGateway;
352
+
353
+ // ── Gateway form state ──
354
+ const [gatewayUrl, setGatewayUrl] = useState(existingGateway?.url ?? "");
355
+ const [gatewayApiKey, setGatewayApiKey] = useState("");
356
+ const [gatewayHeaders, setGatewayHeaders] = useState(
357
+ existingGateway?.headers ? JSON.stringify(existingGateway.headers, null, 2) : "",
358
+ );
359
+ const [savingGw, setSavingGw] = useState(false);
360
+ const [savedGw, setSavedGw] = useState(false);
361
+ const [gwError, setGwError] = useState<string | null>(null);
362
+
363
+ // ── Provider-key form state ──
364
+ const [keyProvider, setKeyProvider] = useState("openai");
365
+ const [keyValue, setKeyValue] = useState("");
366
+ const [showKeyForm, setShowKeyForm] = useState(false);
367
+ const [savingKey, setSavingKey] = useState(false);
368
+ const [deletingKey, setDeletingKey] = useState<string | null>(null);
369
+ const [keyError, setKeyError] = useState<string | null>(null);
370
+
371
+ // ── Managed switch state ──
372
+ const [switching, setSwitching] = useState(false);
373
+
374
+ const usedProviders = new Set(providerKeys.map((k) => k.provider));
375
+ const availableProviders = BYOK_PROVIDERS.filter((p) => !usedProviders.has(p.value));
376
+
377
+ async function refreshKeys() {
378
+ const r = await fetch(`${API_URL}/v1/byok/${projectId}`, { credentials: "include" });
379
+ const d = r.ok ? await r.json() : { data: [] };
380
+ setKeys(d.data ?? []);
381
+ }
382
+
383
+ async function handleAddKey(e: React.FormEvent) {
384
+ e.preventDefault();
385
+ setKeyError(null);
386
+ setSavingKey(true);
387
+ try {
388
+ const res = await fetch(`${API_URL}/v1/byok/${projectId}`, {
389
+ method: "POST",
390
+ credentials: "include",
391
+ headers: { "Content-Type": "application/json" },
392
+ body: JSON.stringify({ provider: keyProvider, key: keyValue.trim() }),
393
+ });
394
+ if (!res.ok) {
395
+ const data = await res.json().catch(() => ({}));
396
+ throw new Error(data.error ?? `Error ${res.status}`);
397
+ }
398
+ await refreshKeys();
399
+ setKeyValue("");
400
+ setShowKeyForm(false);
401
+ } catch (err: any) {
402
+ setKeyError(err.message);
403
+ } finally {
404
+ setSavingKey(false);
405
+ }
406
+ }
407
+
408
+ async function handleDeleteKey(provider: string) {
409
+ setDeletingKey(provider);
410
+ try {
411
+ await fetch(`${API_URL}/v1/byok/${projectId}/${provider}`, {
412
+ method: "DELETE",
413
+ credentials: "include",
414
+ });
415
+ await refreshKeys();
416
+ } catch {} finally {
417
+ setDeletingKey(null);
418
+ }
419
+ }
420
+
421
+ async function handleSaveGateway(e: React.FormEvent) {
422
+ e.preventDefault();
423
+ setGwError(null);
424
+ setSavingGw(true);
425
+ setSavedGw(false);
426
+ try {
427
+ let headers: Record<string, string> | undefined;
428
+ if (gatewayHeaders.trim()) {
429
+ try { headers = JSON.parse(gatewayHeaders.trim()); }
430
+ catch { throw new Error("Invalid JSON in custom headers."); }
431
+ }
432
+ const settingsRes = await fetch(`${API_URL}/v1/projects/${projectId}/settings`, {
433
+ method: "PATCH",
434
+ credentials: "include",
435
+ headers: { "Content-Type": "application/json" },
436
+ body: JSON.stringify({ gateway: { url: gatewayUrl.trim(), headers } }),
437
+ });
438
+ if (!settingsRes.ok) {
439
+ const data = await settingsRes.json().catch(() => ({}));
440
+ throw new Error(data.error ?? `Error ${settingsRes.status}`);
441
+ }
442
+ if (gatewayApiKey.trim()) {
443
+ await fetch(`${API_URL}/v1/byok/${projectId}`, {
444
+ method: "POST",
445
+ credentials: "include",
446
+ headers: { "Content-Type": "application/json" },
447
+ body: JSON.stringify({ provider: "gateway", key: gatewayApiKey.trim(), label: "Gateway API Key" }),
448
+ });
449
+ }
450
+ setSavedGw(true);
451
+ setGatewayApiKey("");
452
+ setTimeout(() => setSavedGw(false), 3000);
453
+ } catch (err: any) {
454
+ setGwError(err.message);
455
+ } finally {
456
+ setSavingGw(false);
457
+ }
458
+ }
459
+
460
+ // Switch back to Polpo managed — clears the custom gateway + its key.
461
+ async function handleSwitchToManaged() {
462
+ setSwitching(true);
463
+ try {
464
+ await fetch(`${API_URL}/v1/projects/${projectId}/settings`, {
465
+ method: "PATCH",
466
+ credentials: "include",
467
+ headers: { "Content-Type": "application/json" },
468
+ body: JSON.stringify({ gateway: null }),
469
+ });
470
+ await fetch(`${API_URL}/v1/byok/${projectId}/gateway`, {
471
+ method: "DELETE",
472
+ credentials: "include",
473
+ }).catch(() => {});
474
+ window.location.reload();
475
+ } finally {
476
+ setSwitching(false);
477
+ }
478
+ }
479
+
480
+ return (
481
+ <section>
482
+ <h2 className="text-lg font-semibold tracking-tight">AI Gateway</h2>
483
+ <p className="mt-1 text-xs text-muted-foreground">
484
+ Choose how this project pays for inference.
485
+ </p>
486
+
487
+ <div className="mt-6">
488
+ <InferenceModeRadio
489
+ value={viewMode}
490
+ onChange={setViewMode}
491
+ customGatewayEnabled={customGatewayEnabled}
492
+ onContactSales={() => {
493
+ window.location.href = `mailto:${SALES_EMAIL}?subject=${encodeURIComponent("Custom gateway — Enterprise")}`;
494
+ }}
495
+ >
496
+ {/* ── Managed panel ── */}
497
+ {viewMode === "managed" && (
498
+ <div className="border border-border bg-card p-5">
499
+ <p className="text-sm font-medium">Polpo managed gateway</p>
500
+ <p className="mt-1 text-xs text-muted-foreground">
501
+ Routes through the Vercel AI Gateway. Inference is deducted from
502
+ your credit balance at list price.
503
+ </p>
504
+ {hasCustomGateway && (
505
+ <button
506
+ onClick={handleSwitchToManaged}
507
+ disabled={switching}
508
+ className="mt-4 inline-flex items-center gap-2 border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:border-foreground/30 hover:bg-foreground/5 disabled:opacity-50"
509
+ >
510
+ {switching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
511
+ Switch to Polpo managed (clears custom gateway)
512
+ </button>
513
+ )}
514
+ {providerKeys.length > 0 && (
515
+ <p className="mt-4 border-t border-border pt-3 text-[11px] text-muted-foreground/70">
516
+ This project also has provider keys. Calls to those providers
517
+ use BYOK (free on Polpo); everything else is managed.
518
+ </p>
519
+ )}
520
+ </div>
521
+ )}
522
+
523
+ {/* ── Provider keys (BYOK) panel ── */}
524
+ {viewMode === "byok" && (
525
+ <div className="border border-border bg-card p-5">
526
+ <p className="text-sm font-medium">Provider keys</p>
527
+ <p className="mt-1 text-xs text-muted-foreground">
528
+ Encrypted per project. Calls to a provider with a key here are
529
+ billed by that provider — free on Polpo.
530
+ </p>
531
+
532
+ {providerKeys.length > 0 && (
533
+ <div className="mt-4 divide-y divide-border border-y border-border">
534
+ {providerKeys.map((k) => {
535
+ const label = BYOK_PROVIDERS.find((p) => p.value === k.provider)?.label ?? k.provider;
536
+ return (
537
+ <div key={k.provider} className="flex items-center justify-between gap-3 py-2.5">
538
+ <div className="flex items-center gap-2 min-w-0">
539
+ <span className="text-sm font-medium">{label}</span>
540
+ <span className="font-mono text-[11px] text-muted-foreground/60 truncate">{k.maskedKey}</span>
541
+ </div>
542
+ <button
543
+ onClick={() => handleDeleteKey(k.provider)}
544
+ disabled={deletingKey === k.provider}
545
+ className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
546
+ aria-label={`Remove ${label} key`}
547
+ >
548
+ {deletingKey === k.provider ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
549
+ </button>
550
+ </div>
551
+ );
552
+ })}
553
+ </div>
554
+ )}
555
+
556
+ {keyError && (
557
+ <div className="mt-3 border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">{keyError}</div>
558
+ )}
559
+
560
+ {!showKeyForm ? (
561
+ <button
562
+ onClick={() => { setKeyProvider(availableProviders[0]?.value ?? "openai"); setShowKeyForm(true); }}
563
+ disabled={availableProviders.length === 0}
564
+ className="mt-4 inline-flex items-center gap-1.5 border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:border-foreground/30 hover:bg-foreground/5 disabled:opacity-50"
565
+ >
566
+ <Plus className="h-3.5 w-3.5" />
567
+ Add provider key
568
+ </button>
569
+ ) : (
570
+ <form onSubmit={handleAddKey} className="mt-4 flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-end">
571
+ <div className="flex-1">
572
+ <label className="block text-[10px] font-mono uppercase tracking-wider text-muted-foreground/60 mb-1">Provider</label>
573
+ <select
574
+ value={keyProvider}
575
+ onChange={(e) => setKeyProvider(e.target.value)}
576
+ className="w-full border border-border bg-background px-2.5 py-2 text-sm focus:border-foreground/30 focus:outline-none appearance-none"
577
+ >
578
+ {availableProviders.map((p) => (
579
+ <option key={p.value} value={p.value}>{p.label}</option>
580
+ ))}
581
+ </select>
582
+ </div>
583
+ <div className="flex-[2]">
584
+ <label className="block text-[10px] font-mono uppercase tracking-wider text-muted-foreground/60 mb-1">API key</label>
585
+ <input
586
+ type="password"
587
+ value={keyValue}
588
+ onChange={(e) => setKeyValue(e.target.value)}
589
+ placeholder="sk-…"
590
+ required
591
+ className="w-full border border-border bg-transparent px-2.5 py-2 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none"
592
+ />
593
+ </div>
594
+ <div className="flex items-center gap-2">
595
+ <button
596
+ type="submit"
597
+ disabled={savingKey || !keyValue.trim()}
598
+ className="inline-flex items-center gap-1.5 bg-foreground text-background px-3 py-2 text-sm font-medium transition-all hover:opacity-90 disabled:opacity-50"
599
+ >
600
+ {savingKey ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
601
+ </button>
602
+ <button
603
+ type="button"
604
+ onClick={() => { setShowKeyForm(false); setKeyError(null); setKeyValue(""); }}
605
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
606
+ >
607
+ Cancel
608
+ </button>
609
+ </div>
610
+ </form>
611
+ )}
612
+ </div>
613
+ )}
614
+
615
+ {/* ── Custom gateway panel (enterprise) ── */}
616
+ {viewMode === "gateway" && customGatewayEnabled && (
617
+ <form onSubmit={handleSaveGateway} className="border border-border bg-card p-5 space-y-4 max-w-lg">
618
+ <div>
619
+ <label className="block text-xs font-medium text-muted-foreground mb-1.5">Gateway URL</label>
620
+ <input
621
+ type="url"
622
+ value={gatewayUrl}
623
+ onChange={(e) => setGatewayUrl(e.target.value)}
624
+ placeholder="https://your-gateway.example.com/v1"
625
+ required
626
+ className="w-full border border-border bg-transparent px-3 py-2.5 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors"
627
+ />
628
+ </div>
629
+ <div>
630
+ <label className="block text-xs font-medium text-muted-foreground mb-1.5">API Key</label>
631
+ <input
632
+ type="password"
633
+ value={gatewayApiKey}
634
+ onChange={(e) => setGatewayApiKey(e.target.value)}
635
+ placeholder="Leave empty to keep existing key"
636
+ className="w-full border border-border bg-transparent px-3 py-2.5 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors"
637
+ />
638
+ </div>
639
+ <div>
640
+ <label className="block text-xs font-medium text-muted-foreground mb-1.5">Custom Headers (JSON, optional)</label>
641
+ <textarea
642
+ value={gatewayHeaders}
643
+ onChange={(e) => setGatewayHeaders(e.target.value)}
644
+ placeholder={'{"X-Custom-Header": "value"}'}
645
+ rows={3}
646
+ className="w-full border border-border bg-transparent px-3 py-2.5 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors resize-y"
647
+ />
648
+ </div>
649
+ {gwError && (
650
+ <div className="border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">{gwError}</div>
651
+ )}
652
+ <div className="flex items-center gap-3">
653
+ <button
654
+ type="submit"
655
+ disabled={savingGw || !gatewayUrl.trim()}
656
+ className="inline-flex items-center gap-2 bg-foreground text-background px-4 py-2 text-sm font-medium transition-all hover:opacity-90 disabled:opacity-50"
657
+ >
658
+ {savingGw ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save gateway"}
659
+ </button>
660
+ {savedGw && <span className="text-xs text-muted-foreground">Saved.</span>}
661
+ </div>
662
+ </form>
663
+ )}
664
+ </InferenceModeRadio>
665
+ </div>
666
+ </section>
667
+ );
668
+ }
669
+
670
+ /* ── Integrations Tab ─────────────────────────────────────── */
671
+
672
+ export interface AutumnStatusResponse {
673
+ connected: boolean;
674
+ status?: "active" | "error" | "revoked";
675
+ env?: "sandbox" | "live" | "unknown";
676
+ featureIds?: {
677
+ completions: string;
678
+ tasks: string;
679
+ inferenceUsd: string;
680
+ inputTokens?: string | null;
681
+ outputTokens?: string | null;
682
+ };
683
+ eventsCount?: number;
684
+ errorsCount?: number;
685
+ lastUsedAt?: string | null;
686
+ lastError?: string | null;
687
+ lastErrorAt?: string | null;
688
+ createdAt?: string;
689
+ }
690
+
691
+ /**
692
+ * Project integrations tab. Today only Autumn billing pass-through —
693
+ * future identity providers, Stripe direct, etc. live as additional cards
694
+ * here without changing the tab layout.
695
+ *
696
+ * The Autumn card has two states:
697
+ * - **Disconnected**: form to paste the builder's Autumn secret key.
698
+ * On submit, Polpo validates the key, creates the polpo_* features in
699
+ * the builder's Autumn (idempotent), and persists the encrypted secret.
700
+ * - **Active**: status + telemetry (events fired, errors, last error
701
+ * reason) + a "Disconnect" button. The plaintext key is never echoed
702
+ * back — once submitted, only counters and feature ids are visible.
703
+ */
704
+ type ConnectionState = "disconnected" | "incomplete" | "active";
705
+ type WizardStep = "connect" | "plans" | "status";
706
+
707
+ /**
708
+ * Derive the connection state from the raw status response. Today this is
709
+ * a client-side heuristic — once the backend grows a "verify plan
710
+ * attachment" endpoint, it can replace `incomplete` here without touching
711
+ * any of the UI below.
712
+ *
713
+ * TODO(verify-endpoint): swap `eventsCount === 0` for a real
714
+ * `plansAttached` boolean from the API.
715
+ */
716
+ function deriveConnectionState(status: AutumnStatusResponse | undefined): ConnectionState {
717
+ if (!status?.connected) return "disconnected";
718
+ if (!status.eventsCount || status.eventsCount === 0) return "incomplete";
719
+ return "active";
720
+ }
721
+
722
+ function IntegrationsTab({
723
+ projectId,
724
+ initialStatus,
725
+ }: {
726
+ projectId: string;
727
+ initialStatus?: AutumnStatusResponse | null;
728
+ }) {
729
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
730
+ const queryClient = useQueryClient();
731
+
732
+ const { data: status, isLoading } = useQuery<AutumnStatusResponse>({
733
+ queryKey: ["autumn-integration", projectId],
734
+ queryFn: async () => {
735
+ const res = await fetch(
736
+ `${API_URL}/v1/integrations/${projectId}/autumn`,
737
+ { credentials: "include" },
738
+ );
739
+ if (!res.ok) throw new Error(`Failed to load (${res.status})`);
740
+ const json = await res.json();
741
+ return json.data as AutumnStatusResponse;
742
+ },
743
+ // SSR-prefetched: the integration row was already loaded server-side
744
+ // alongside the project, so we hydrate without a loading flash. Keep it
745
+ // fresh for a minute, then revalidate in the background.
746
+ initialData: initialStatus ?? undefined,
747
+ staleTime: 60_000,
748
+ });
749
+
750
+ const [dialogOpen, setDialogOpen] = useState(false);
751
+
752
+ const connectionState = deriveConnectionState(status);
753
+
754
+ // Pick the entry step based on the card's current state.
755
+ const initialStep: WizardStep =
756
+ connectionState === "disconnected"
757
+ ? "connect"
758
+ : connectionState === "incomplete"
759
+ ? "plans"
760
+ : "status";
761
+
762
+ return (
763
+ <section className="space-y-6">
764
+ <div>
765
+ <h2 className="text-base font-semibold tracking-tight">Integrations</h2>
766
+ <p className="mt-1 text-sm text-muted-foreground">
767
+ Connect your stack to Polpo.
768
+ </p>
769
+ </div>
770
+
771
+ {/* Autumn card */}
772
+ <div className="rounded-lg border border-border p-5">
773
+ <div className="flex items-start gap-4">
774
+ {/* Logo */}
775
+ <img
776
+ src="/logos/autumn-icon.svg"
777
+ alt="Autumn"
778
+ className="h-12 w-12 shrink-0 rounded"
779
+ />
780
+
781
+ {/* Title + description + status pill */}
782
+ <div className="min-w-0 flex-1">
783
+ <h3 className="text-sm font-semibold leading-none">Autumn</h3>
784
+ <p className="mt-1.5 text-xs text-muted-foreground">
785
+ Per-end-user billing pass-through. Polpo fires usage events to
786
+ your Autumn — you manage plans &amp; pricing there.
787
+ </p>
788
+ {!isLoading && connectionState !== "disconnected" && (
789
+ <div className="mt-2.5">
790
+ <StatusPill state={connectionState} env={status?.env} />
791
+ </div>
792
+ )}
793
+ {isLoading && (
794
+ <div className="mt-2.5 flex items-center gap-2 text-[11px] text-muted-foreground">
795
+ <Loader2 className="h-3 w-3 animate-spin" />
796
+ Loading status…
797
+ </div>
798
+ )}
799
+ </div>
800
+
801
+ {/* Action button */}
802
+ <div className="shrink-0">
803
+ {!isLoading && (
804
+ <button
805
+ type="button"
806
+ onClick={() => setDialogOpen(true)}
807
+ className={
808
+ connectionState === "disconnected"
809
+ ? "inline-flex items-center gap-2 bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-all hover:opacity-90"
810
+ : "inline-flex items-center gap-2 rounded border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-secondary transition-colors"
811
+ }
812
+ >
813
+ {connectionState === "disconnected"
814
+ ? "Connect Autumn"
815
+ : connectionState === "incomplete"
816
+ ? "Finish setup"
817
+ : "Manage"}
818
+ </button>
819
+ )}
820
+ </div>
821
+ </div>
822
+ </div>
823
+
824
+ <AutumnWizardDialog
825
+ open={dialogOpen}
826
+ onOpenChange={setDialogOpen}
827
+ initialStep={initialStep}
828
+ mode={connectionState === "active" ? "manage" : "wizard"}
829
+ projectId={projectId}
830
+ apiUrl={API_URL}
831
+ status={status}
832
+ onChanged={() =>
833
+ queryClient.invalidateQueries({
834
+ queryKey: ["autumn-integration", projectId],
835
+ })
836
+ }
837
+ />
838
+ </section>
839
+ );
840
+ }
841
+
842
+ /* ── Status pill (card) ─────────────────────────────────── */
843
+
844
+ function StatusPill({
845
+ state,
846
+ env,
847
+ }: {
848
+ state: ConnectionState;
849
+ env?: AutumnStatusResponse["env"];
850
+ }) {
851
+ if (state === "active") {
852
+ return (
853
+ <span className="inline-flex items-center gap-1.5 rounded bg-brand/10 px-2 py-0.5 text-[11px] font-medium text-brand">
854
+ <span className="relative flex h-1.5 w-1.5">
855
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand opacity-60" />
856
+ <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand" />
857
+ </span>
858
+ Bridge active
859
+ {env && env !== "unknown" && (
860
+ <span className="ml-1 uppercase tracking-wide opacity-70">{env}</span>
861
+ )}
862
+ </span>
863
+ );
864
+ }
865
+ if (state === "incomplete") {
866
+ return (
867
+ <span className="inline-flex items-center gap-1.5 rounded bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-400">
868
+ <span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
869
+ Action required
870
+ </span>
871
+ );
872
+ }
873
+ return (
874
+ <span className="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
875
+ <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
876
+ Disconnected
877
+ </span>
878
+ );
879
+ }
880
+
881
+ /* ── Wizard Dialog ──────────────────────────────────────── */
882
+
883
+ const WIZARD_STEPS: {
884
+ id: WizardStep;
885
+ n: number;
886
+ title: string;
887
+ badge: string;
888
+ icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
889
+ }[] = [
890
+ { id: "connect", n: 1, title: "Connect", badge: "Step 1", icon: Plug },
891
+ { id: "plans", n: 2, title: "Plans & verify", badge: "Step 2", icon: FileText },
892
+ { id: "status", n: 3, title: "Test event", badge: "Step 3", icon: CheckCircle2 },
893
+ ];
894
+
895
+ /**
896
+ * Segmented step card — mirrors `OptionCard` in connect-dialog. Three of
897
+ * these glued together by `border + divide-x` form the navigation strip
898
+ * at the top of the wizard.
899
+ */
900
+ function StepOptionCard({
901
+ icon: Icon,
902
+ title,
903
+ badge,
904
+ active,
905
+ completed,
906
+ disabled,
907
+ onClick,
908
+ }: {
909
+ icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
910
+ title: string;
911
+ badge: string;
912
+ active: boolean;
913
+ completed: boolean;
914
+ disabled: boolean;
915
+ onClick: () => void;
916
+ }) {
917
+ // Visual hierarchy:
918
+ // - completed (and not the current step) → green wash + check overlay
919
+ // - active (current step) → secondary fill, normal icon
920
+ // - upcoming → dimmed, hover only
921
+ const tone = active
922
+ ? "bg-secondary"
923
+ : completed
924
+ ? "bg-emerald-500/8 hover:bg-emerald-500/12"
925
+ : "hover:bg-secondary/60";
926
+
927
+ return (
928
+ <button
929
+ type="button"
930
+ onClick={disabled ? undefined : onClick}
931
+ disabled={disabled}
932
+ className={`group relative flex flex-1 flex-col items-center justify-center gap-3 py-4 transition-colors ${tone} ${
933
+ disabled ? "cursor-not-allowed opacity-50 hover:bg-transparent" : ""
934
+ }`}
935
+ aria-current={active ? "step" : undefined}
936
+ >
937
+ {completed && !active && (
938
+ <span className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-background">
939
+ <Check className="h-2.5 w-2.5" strokeWidth={3} />
940
+ </span>
941
+ )}
942
+ <Icon
943
+ className={`h-6 w-6 ${
944
+ completed && !active
945
+ ? "text-emerald-600 dark:text-emerald-400"
946
+ : active
947
+ ? "text-muted-foreground"
948
+ : "text-muted-foreground/60 group-hover:text-muted-foreground"
949
+ }`}
950
+ strokeWidth={1.5}
951
+ />
952
+ <div className="flex flex-col items-center gap-1.5">
953
+ <span
954
+ className={`text-[13px] leading-[18px] ${
955
+ active ? "text-foreground" : "text-foreground/90"
956
+ }`}
957
+ >
958
+ {title}
959
+ </span>
960
+ <span
961
+ className={`inline-flex items-center rounded px-2 py-0.5 text-[11px] font-medium ${
962
+ completed && !active
963
+ ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
964
+ : "bg-brand/10 text-brand"
965
+ }`}
966
+ >
967
+ {completed && !active ? "Done" : badge}
968
+ </span>
969
+ </div>
970
+ </button>
971
+ );
972
+ }
973
+
974
+ function AutumnWizardDialog({
975
+ open,
976
+ onOpenChange,
977
+ initialStep,
978
+ mode,
979
+ projectId,
980
+ apiUrl,
981
+ status,
982
+ onChanged,
983
+ }: {
984
+ open: boolean;
985
+ onOpenChange: (v: boolean) => void;
986
+ initialStep: WizardStep;
987
+ /**
988
+ * "wizard" → segmented step strip, used when the integration is being
989
+ * configured for the first time or finishing setup.
990
+ *
991
+ * "manage" → compact view (telemetry + disconnect only). The wizard would
992
+ * just be noise once the bridge is fully active.
993
+ */
994
+ mode: "wizard" | "manage";
995
+ projectId: string;
996
+ apiUrl: string;
997
+ status: AutumnStatusResponse | undefined;
998
+ onChanged: () => void;
999
+ }) {
1000
+ const queryClient = useQueryClient();
1001
+ const [step, setStep] = useState<WizardStep>(initialStep);
1002
+
1003
+ // Reset step every time the dialog re-opens so the entry point is
1004
+ // recomputed against the latest connection state.
1005
+ useEffect(() => {
1006
+ if (open) setStep(initialStep);
1007
+ }, [open, initialStep]);
1008
+
1009
+ const [apiKey, setApiKey] = useState("");
1010
+ const [submitError, setSubmitError] = useState<string | null>(null);
1011
+ const [createdSummary, setCreatedSummary] = useState<{
1012
+ createdFeatures?: number;
1013
+ reusedFeatures?: number;
1014
+ } | null>(null);
1015
+ // Custom confirmation dialog state — replaces window.confirm() so we get
1016
+ // a styled, themed confirm UI instead of the browser's native modal.
1017
+ const [confirmDisconnect, setConfirmDisconnect] = useState(false);
1018
+
1019
+ const connectMutation = useMutation({
1020
+ mutationFn: async (key: string) => {
1021
+ const res = await fetch(`${apiUrl}/v1/integrations/${projectId}/autumn`, {
1022
+ method: "POST",
1023
+ credentials: "include",
1024
+ headers: { "Content-Type": "application/json" },
1025
+ body: JSON.stringify({ apiKey: key }),
1026
+ });
1027
+ const json = await res.json().catch(() => ({}));
1028
+ if (!res.ok) throw new Error(json?.error ?? `Failed (${res.status})`);
1029
+ return json.data as {
1030
+ status: string;
1031
+ featureIds: AutumnStatusResponse["featureIds"];
1032
+ env: AutumnStatusResponse["env"];
1033
+ createdFeatures?: number;
1034
+ reusedFeatures?: number;
1035
+ };
1036
+ },
1037
+ onSuccess: (data) => {
1038
+ setApiKey("");
1039
+ setSubmitError(null);
1040
+ setCreatedSummary({
1041
+ createdFeatures: data.createdFeatures,
1042
+ reusedFeatures: data.reusedFeatures,
1043
+ });
1044
+ onChanged();
1045
+ // Auto-advance to step 2 after a beat so the user sees the success state.
1046
+ const t = setTimeout(() => setStep("plans"), 1000);
1047
+ return () => clearTimeout(t);
1048
+ },
1049
+ onError: (err: Error) => {
1050
+ setSubmitError(err.message);
1051
+ },
1052
+ });
1053
+
1054
+ const disconnectMutation = useMutation({
1055
+ mutationFn: async () => {
1056
+ const res = await fetch(`${apiUrl}/v1/integrations/${projectId}/autumn`, {
1057
+ method: "DELETE",
1058
+ credentials: "include",
1059
+ });
1060
+ if (!res.ok) throw new Error(`Failed (${res.status})`);
1061
+ },
1062
+ onSuccess: () => {
1063
+ onChanged();
1064
+ onOpenChange(false);
1065
+ },
1066
+ });
1067
+
1068
+ // Real verify call — asks Autumn whether ANY polpo_* feature is attached
1069
+ // to ANY of the builder's plans. The bridge is "ready" with as little as
1070
+ // one feature on one plan, by design (different products bill on different
1071
+ // dimensions). When the call comes back ready, we advance to step 3.
1072
+ const [verifyResult, setVerifyResult] = useState<VerifyData | null>(null);
1073
+ const verifyMutation = useMutation({
1074
+ mutationFn: async (): Promise<VerifyData> => {
1075
+ const res = await fetch(
1076
+ `${apiUrl}/v1/integrations/${projectId}/autumn/verify`,
1077
+ { credentials: "include" },
1078
+ );
1079
+ const json = await res.json().catch(() => ({}));
1080
+ if (!res.ok) throw new Error(json?.error ?? `Failed (${res.status})`);
1081
+ return json.data as VerifyData;
1082
+ },
1083
+ onSuccess: (data) => {
1084
+ setVerifyResult(data);
1085
+ onChanged();
1086
+ },
1087
+ });
1088
+
1089
+ // Test event — fires a synthetic track call so the builder can prove the
1090
+ // bridge end-to-end before real users hit it.
1091
+ const testEventMutation = useMutation({
1092
+ mutationFn: async () => {
1093
+ const res = await fetch(
1094
+ `${apiUrl}/v1/integrations/${projectId}/autumn/test`,
1095
+ { method: "POST", credentials: "include" },
1096
+ );
1097
+ const json = await res.json().catch(() => ({}));
1098
+ if (!res.ok) throw new Error(json?.error ?? `Failed (${res.status})`);
1099
+ return json.data as { customerId: string; featureId: string; value: number };
1100
+ },
1101
+ onSuccess: () => {
1102
+ onChanged();
1103
+ // Re-verify after a test event so the dashboard reflects new state.
1104
+ queryClient.invalidateQueries({ queryKey: ["autumn-integration", projectId] });
1105
+ },
1106
+ });
1107
+
1108
+ // Step navigation guard: never let the user jump forward past their
1109
+ // current progress (you can't "verify" before you "connect").
1110
+ // Going back to a completed step is fine — informational only.
1111
+ const order: WizardStep[] = ["connect", "plans", "status"];
1112
+ const connected = !!status?.connected;
1113
+ const eventsFired = (status?.eventsCount ?? 0) > 0;
1114
+ function canJumpTo(target: WizardStep): boolean {
1115
+ const targetIdx = order.indexOf(target);
1116
+ const currentIdx = order.indexOf(step);
1117
+ if (targetIdx <= currentIdx) return true;
1118
+ if (target === "plans") return connected || connectMutation.isSuccess;
1119
+ if (target === "status") return connected;
1120
+ return false;
1121
+ }
1122
+
1123
+ // Completion state per step — drives the green "Done" badge in the strip
1124
+ // and the Connect-step "already connected" panel.
1125
+ const completedSteps: Record<WizardStep, boolean> = {
1126
+ connect: connected || connectMutation.isSuccess,
1127
+ plans: !!verifyResult?.ready || eventsFired,
1128
+ status: eventsFired || testEventMutation.isSuccess,
1129
+ };
1130
+
1131
+ return (
1132
+ <Dialog open={open} onOpenChange={onOpenChange}>
1133
+ <DialogContent className="sm:max-w-4xl bg-card ring-foreground/15 shadow-2xl">
1134
+ <DialogHeader className="pb-1">
1135
+ <DialogTitle className="text-base font-normal">
1136
+ <span className="inline-flex items-center gap-3">
1137
+ <img
1138
+ src="/logos/autumn-icon.svg"
1139
+ alt=""
1140
+ className="h-9 w-9 rounded"
1141
+ />
1142
+ {mode === "manage" || connected ? (
1143
+ <span>
1144
+ Manage <span className="font-semibold">Autumn</span> integration
1145
+ </span>
1146
+ ) : (
1147
+ <span>
1148
+ Connect <span className="font-semibold">Autumn</span>
1149
+ </span>
1150
+ )}
1151
+ </span>
1152
+ </DialogTitle>
1153
+ <DialogDescription className="text-sm">
1154
+ Per-end-user billing pass-through for your AI app. Plans, pricing,
1155
+ balance UIs stay in Autumn — Polpo is the rail.{" "}
1156
+ <a
1157
+ href="https://docs.useautumn.com"
1158
+ target="_blank"
1159
+ rel="noopener noreferrer"
1160
+ className="font-medium text-foreground hover:underline underline-offset-4"
1161
+ >
1162
+ Read the docs
1163
+ </a>
1164
+ </DialogDescription>
1165
+ </DialogHeader>
1166
+
1167
+ {mode === "manage" ? (
1168
+ <ManageView
1169
+ status={status}
1170
+ onSendTestEvent={() => testEventMutation.mutate()}
1171
+ testPending={testEventMutation.isPending}
1172
+ testError={
1173
+ testEventMutation.error instanceof Error
1174
+ ? testEventMutation.error.message
1175
+ : null
1176
+ }
1177
+ testResult={testEventMutation.data ?? null}
1178
+ />
1179
+ ) : (
1180
+ <>
1181
+ {/* Segmented step strip — three squared cards glued together with
1182
+ a single outer border + vertical dividers. Same shape as the
1183
+ ConnectDialog tab strip. */}
1184
+ <div className="mt-2 flex items-stretch overflow-hidden border border-border divide-x divide-border">
1185
+ {WIZARD_STEPS.map((s) => (
1186
+ <StepOptionCard
1187
+ key={s.id}
1188
+ icon={s.icon}
1189
+ title={s.title}
1190
+ badge={s.badge}
1191
+ active={step === s.id}
1192
+ completed={completedSteps[s.id]}
1193
+ disabled={!canJumpTo(s.id)}
1194
+ onClick={() => setStep(s.id)}
1195
+ />
1196
+ ))}
1197
+ </div>
1198
+
1199
+ {/* Per-step content — 240px label column on the left, content
1200
+ on the right. min-h keeps dialog height stable across steps. */}
1201
+ <div className="mt-6 min-h-[160px]">
1202
+ {step === "connect" && (
1203
+ <ConnectStep
1204
+ apiKey={apiKey}
1205
+ setApiKey={setApiKey}
1206
+ submitError={submitError}
1207
+ isPending={connectMutation.isPending}
1208
+ isSuccess={connectMutation.isSuccess}
1209
+ alreadyConnected={connected && !connectMutation.isSuccess}
1210
+ env={status?.env}
1211
+ createdSummary={createdSummary}
1212
+ featureIds={status?.featureIds ?? connectMutation.data?.featureIds}
1213
+ onSubmit={(k) => connectMutation.mutate(k)}
1214
+ />
1215
+ )}
1216
+
1217
+ {step === "plans" && (
1218
+ <PlansStep
1219
+ featureIds={status?.featureIds ?? connectMutation.data?.featureIds}
1220
+ onVerify={() => verifyMutation.mutate()}
1221
+ verifyPending={verifyMutation.isPending}
1222
+ verifyError={
1223
+ verifyMutation.error instanceof Error
1224
+ ? verifyMutation.error.message
1225
+ : null
1226
+ }
1227
+ verifyResult={verifyResult}
1228
+ />
1229
+ )}
1230
+
1231
+ {step === "status" && (
1232
+ <StatusStep
1233
+ status={status}
1234
+ onSendTestEvent={() => testEventMutation.mutate()}
1235
+ testPending={testEventMutation.isPending}
1236
+ testError={
1237
+ testEventMutation.error instanceof Error
1238
+ ? testEventMutation.error.message
1239
+ : null
1240
+ }
1241
+ testResult={testEventMutation.data ?? null}
1242
+ />
1243
+ )}
1244
+ </div>
1245
+ </>
1246
+ )}
1247
+
1248
+ <DialogFooter className="mt-2 flex items-center justify-between gap-3 sm:justify-between">
1249
+ {connected ? (
1250
+ <button
1251
+ type="button"
1252
+ onClick={() => setConfirmDisconnect(true)}
1253
+ disabled={disconnectMutation.isPending}
1254
+ className="inline-flex items-center gap-2 rounded border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-destructive hover:border-destructive/40 transition-colors disabled:opacity-50"
1255
+ >
1256
+ {disconnectMutation.isPending && (
1257
+ <Loader2 className="h-3 w-3 animate-spin" />
1258
+ )}
1259
+ Disconnect
1260
+ </button>
1261
+ ) : (
1262
+ <span />
1263
+ )}
1264
+ <DialogClose className="rounded-md border border-border px-4 py-2 text-sm hover:border-foreground/30 transition-colors">
1265
+ Close
1266
+ </DialogClose>
1267
+ </DialogFooter>
1268
+ </DialogContent>
1269
+
1270
+ {/* Custom confirmation modal for disconnect — replaces window.confirm */}
1271
+ <Dialog open={confirmDisconnect} onOpenChange={setConfirmDisconnect}>
1272
+ <DialogContent className="sm:max-w-md bg-card ring-foreground/15">
1273
+ <DialogHeader>
1274
+ <DialogTitle className="text-base">
1275
+ Disconnect Autumn?
1276
+ </DialogTitle>
1277
+ <DialogDescription>
1278
+ Polpo will stop firing usage events. Your plans, customers, and
1279
+ balances in Autumn are not affected — you can reconnect at any
1280
+ time by pasting the same key.
1281
+ </DialogDescription>
1282
+ </DialogHeader>
1283
+ <DialogFooter className="gap-2 sm:justify-end">
1284
+ <button
1285
+ type="button"
1286
+ onClick={() => setConfirmDisconnect(false)}
1287
+ className="rounded border border-border px-4 py-2 text-sm hover:border-foreground/30 transition-colors"
1288
+ >
1289
+ Cancel
1290
+ </button>
1291
+ <button
1292
+ type="button"
1293
+ onClick={() => {
1294
+ setConfirmDisconnect(false);
1295
+ disconnectMutation.mutate();
1296
+ }}
1297
+ disabled={disconnectMutation.isPending}
1298
+ className="inline-flex items-center gap-2 rounded bg-destructive text-destructive-foreground px-4 py-2 text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
1299
+ >
1300
+ {disconnectMutation.isPending && (
1301
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1302
+ )}
1303
+ Disconnect
1304
+ </button>
1305
+ </DialogFooter>
1306
+ </DialogContent>
1307
+ </Dialog>
1308
+ </Dialog>
1309
+ );
1310
+ }
1311
+
1312
+ /* ── Manage view (compact, when integration is fully active) ─────── */
1313
+
1314
+ function ManageView({
1315
+ status,
1316
+ onSendTestEvent,
1317
+ testPending,
1318
+ testError,
1319
+ testResult,
1320
+ }: {
1321
+ status: AutumnStatusResponse | undefined;
1322
+ onSendTestEvent: () => void;
1323
+ testPending: boolean;
1324
+ testError: string | null;
1325
+ testResult: { customerId: string; featureId: string; value: number } | null;
1326
+ }) {
1327
+ return (
1328
+ <div className="mt-4 flex flex-col gap-4">
1329
+ <div className="flex items-center gap-2 border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5 text-xs text-emerald-700 dark:text-emerald-400">
1330
+ <CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
1331
+ <span className="font-medium">
1332
+ Bridge active
1333
+ {status?.env && status.env !== "unknown" ? ` · ${status.env}` : ""}
1334
+ </span>
1335
+ <span className="opacity-80">
1336
+ — Polpo is firing usage events to your Autumn account.
1337
+ </span>
1338
+ </div>
1339
+
1340
+ <div className="grid grid-cols-3 gap-3">
1341
+ <Stat label="Events fired" value={String(status?.eventsCount ?? 0)} />
1342
+ <Stat
1343
+ label="Errors"
1344
+ value={String(status?.errorsCount ?? 0)}
1345
+ tone={status?.errorsCount && status.errorsCount > 0 ? "warn" : "ok"}
1346
+ />
1347
+ <Stat
1348
+ label="Last event"
1349
+ value={status?.lastUsedAt ? formatRelative(status.lastUsedAt) : "—"}
1350
+ />
1351
+ </div>
1352
+
1353
+ <div className="flex flex-wrap items-center gap-3">
1354
+ <button
1355
+ type="button"
1356
+ onClick={onSendTestEvent}
1357
+ disabled={testPending}
1358
+ className="inline-flex items-center gap-2 rounded border border-foreground/20 px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary transition-colors disabled:opacity-50"
1359
+ >
1360
+ {testPending ? (
1361
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1362
+ ) : (
1363
+ <Plug className="h-3.5 w-3.5" />
1364
+ )}
1365
+ {testResult ? "Send another" : "Send test event"}
1366
+ </button>
1367
+ <a
1368
+ href="https://app.useautumn.com/customers"
1369
+ target="_blank"
1370
+ rel="noreferrer"
1371
+ className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
1372
+ >
1373
+ Open Autumn customers
1374
+ <ExternalLink className="h-3 w-3" />
1375
+ </a>
1376
+ </div>
1377
+
1378
+ {testError && (
1379
+ <div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
1380
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1381
+ <span>{testError}</span>
1382
+ </div>
1383
+ )}
1384
+
1385
+ {testResult && (
1386
+ <div className="flex items-start gap-2 border border-emerald-500/30 bg-emerald-500/5 p-2.5 text-xs text-emerald-700 dark:text-emerald-400">
1387
+ <CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1388
+ <div className="min-w-0">
1389
+ <div className="font-medium">Test event delivered</div>
1390
+ <div className="mt-0.5 font-mono opacity-80">
1391
+ {testResult.featureId} += {testResult.value} for{" "}
1392
+ {testResult.customerId}
1393
+ </div>
1394
+ </div>
1395
+ </div>
1396
+ )}
1397
+
1398
+ {status?.lastError && (
1399
+ <div className="flex items-start gap-2 border border-amber-500/30 bg-amber-500/5 p-2.5 text-xs text-amber-700 dark:text-amber-400">
1400
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1401
+ <div className="min-w-0">
1402
+ <div className="font-medium">Last error</div>
1403
+ <div className="mt-0.5 break-words font-mono text-[11px] opacity-80">
1404
+ {status.lastError}
1405
+ </div>
1406
+ {status.lastErrorAt && (
1407
+ <div className="mt-0.5 text-[11px] opacity-70">
1408
+ {formatRelative(status.lastErrorAt)}
1409
+ </div>
1410
+ )}
1411
+ </div>
1412
+ </div>
1413
+ )}
1414
+ </div>
1415
+ );
1416
+ }
1417
+
1418
+ /* ── Step 1: Connect ────────────────────────────────────── */
1419
+
1420
+ function ConnectStep({
1421
+ apiKey,
1422
+ setApiKey,
1423
+ submitError,
1424
+ isPending,
1425
+ isSuccess,
1426
+ alreadyConnected,
1427
+ env,
1428
+ createdSummary,
1429
+ featureIds,
1430
+ onSubmit,
1431
+ }: {
1432
+ apiKey: string;
1433
+ setApiKey: (v: string) => void;
1434
+ submitError: string | null;
1435
+ isPending: boolean;
1436
+ isSuccess: boolean;
1437
+ /** True when the dialog was opened on a project that's already connected
1438
+ * (status.connected) but the user navigated back to Step 1 to inspect or
1439
+ * rotate the key. We never expose the plaintext key. */
1440
+ alreadyConnected: boolean;
1441
+ env: AutumnStatusResponse["env"];
1442
+ createdSummary: { createdFeatures?: number; reusedFeatures?: number } | null;
1443
+ featureIds: AutumnStatusResponse["featureIds"];
1444
+ onSubmit: (key: string) => void;
1445
+ }) {
1446
+ // Already connected from a previous session — show a "Connected" panel
1447
+ // (we never fetched the plaintext key) instead of the empty form. The
1448
+ // disconnect lives in the dialog footer, so this view is purely
1449
+ // informational + a key-rotation affordance.
1450
+ if (alreadyConnected) {
1451
+ return (
1452
+ <div className="flex gap-6">
1453
+ <div className="flex w-[240px] shrink-0 flex-col gap-2">
1454
+ <p className="text-sm font-medium leading-6 text-foreground">Connected</p>
1455
+ <p className="text-sm leading-6 text-muted-foreground">
1456
+ Your Autumn key is already linked. Drop a new key below to rotate
1457
+ it, or hit Disconnect at the bottom of this dialog.
1458
+ </p>
1459
+ <a
1460
+ href="https://app.useautumn.com/dev?tab=api_keys"
1461
+ target="_blank"
1462
+ rel="noreferrer"
1463
+ className="self-start text-xs font-medium text-foreground hover:underline underline-offset-4"
1464
+ >
1465
+ Manage keys in Autumn →
1466
+ </a>
1467
+ </div>
1468
+ <div className="flex-1 min-w-0 flex flex-col gap-3">
1469
+ <div className="flex items-start gap-2.5 border border-emerald-500/30 bg-emerald-500/5 p-3 text-sm text-emerald-700 dark:text-emerald-400">
1470
+ <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
1471
+ <div className="min-w-0">
1472
+ <p className="font-medium">
1473
+ Connected{env && env !== "unknown" ? ` to ${env}` : ""}.
1474
+ </p>
1475
+ <p className="mt-0.5 text-xs opacity-80">
1476
+ For security we never display the saved key. Paste a new one
1477
+ below to overwrite it.
1478
+ </p>
1479
+ </div>
1480
+ </div>
1481
+ <form
1482
+ onSubmit={(e) => {
1483
+ e.preventDefault();
1484
+ if (!apiKey.trim()) return;
1485
+ onSubmit(apiKey.trim());
1486
+ }}
1487
+ className="flex items-stretch gap-2"
1488
+ >
1489
+ <input
1490
+ id="autumn-api-key"
1491
+ type="password"
1492
+ value={apiKey}
1493
+ onChange={(e) => setApiKey(e.target.value)}
1494
+ placeholder="am_sk_test_… or am_sk_live_… (rotate)"
1495
+ autoComplete="off"
1496
+ spellCheck={false}
1497
+ className="flex-1 border border-border bg-transparent px-3 py-2.5 text-sm font-mono focus:border-foreground/30 focus:outline-none transition-colors"
1498
+ />
1499
+ <button
1500
+ type="submit"
1501
+ disabled={!apiKey.trim() || isPending}
1502
+ className="inline-flex items-center gap-2 rounded border border-border px-4 py-2.5 text-sm font-medium hover:border-foreground/30 transition-colors disabled:opacity-50"
1503
+ >
1504
+ {isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
1505
+ Rotate
1506
+ </button>
1507
+ </form>
1508
+ {submitError && (
1509
+ <div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
1510
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1511
+ <span>{submitError}</span>
1512
+ </div>
1513
+ )}
1514
+ {featureIds && <FeatureIdCards featureIds={featureIds} />}
1515
+ </div>
1516
+ </div>
1517
+ );
1518
+ }
1519
+
1520
+ if (isSuccess && createdSummary) {
1521
+ const total =
1522
+ (createdSummary.createdFeatures ?? 0) + (createdSummary.reusedFeatures ?? 0);
1523
+ return (
1524
+ <div className="flex gap-6">
1525
+ <div className="flex w-[240px] shrink-0 flex-col gap-2">
1526
+ <p className="text-sm font-medium leading-6 text-foreground">Connected</p>
1527
+ <p className="text-sm leading-6 text-muted-foreground">
1528
+ Polpo is wired up to your Autumn account. The features below are ready
1529
+ to be attached to a plan.
1530
+ </p>
1531
+ </div>
1532
+ <div className="flex-1 min-w-0 flex flex-col gap-3">
1533
+ <div className="flex items-start gap-2.5 border border-brand/30 bg-brand/5 p-3 text-sm">
1534
+ <Check className="mt-0.5 h-4 w-4 shrink-0 text-brand" />
1535
+ <div className="min-w-0">
1536
+ <p className="font-medium text-foreground">
1537
+ Connected. {total > 0
1538
+ ? `${total} feature${total === 1 ? "" : "s"} ready in your Autumn account.`
1539
+ : "Polpo is ready to fire events."}
1540
+ </p>
1541
+ {(createdSummary.createdFeatures ?? 0) > 0 && (
1542
+ <p className="mt-0.5 text-xs text-muted-foreground">
1543
+ Created {createdSummary.createdFeatures} new
1544
+ {createdSummary.reusedFeatures
1545
+ ? `, reused ${createdSummary.reusedFeatures}`
1546
+ : ""}
1547
+ .
1548
+ </p>
1549
+ )}
1550
+ </div>
1551
+ </div>
1552
+ {featureIds && <FeatureIdCards featureIds={featureIds} />}
1553
+ <p className="text-xs text-muted-foreground">Moving to the next step…</p>
1554
+ </div>
1555
+ </div>
1556
+ );
1557
+ }
1558
+
1559
+ return (
1560
+ <form
1561
+ onSubmit={(e) => {
1562
+ e.preventDefault();
1563
+ if (!apiKey.trim()) return;
1564
+ onSubmit(apiKey.trim());
1565
+ }}
1566
+ className="flex gap-6"
1567
+ >
1568
+ <div className="flex w-[240px] shrink-0 flex-col gap-2">
1569
+ <p className="text-sm font-medium leading-6 text-foreground">Autumn secret key</p>
1570
+ <p className="text-sm leading-6 text-muted-foreground">
1571
+ Polpo will fire usage events to your Autumn so you can charge your
1572
+ end-users. Plans, pricing, and balance UI stay in Autumn — Polpo is
1573
+ the rail.
1574
+ </p>
1575
+ <a
1576
+ href="https://app.useautumn.com/dev?tab=api_keys"
1577
+ target="_blank"
1578
+ rel="noreferrer"
1579
+ className="self-start text-xs font-medium text-foreground hover:underline underline-offset-4"
1580
+ >
1581
+ Get your secret key →
1582
+ </a>
1583
+ </div>
1584
+ <div className="flex-1 min-w-0 flex flex-col gap-3">
1585
+ <div className="flex items-stretch gap-2">
1586
+ <input
1587
+ id="autumn-api-key"
1588
+ type="password"
1589
+ value={apiKey}
1590
+ onChange={(e) => setApiKey(e.target.value)}
1591
+ placeholder="am_sk_test_… or am_sk_live_…"
1592
+ autoComplete="off"
1593
+ spellCheck={false}
1594
+ className="flex-1 border border-border bg-transparent px-3 py-2.5 text-sm font-mono focus:border-foreground/30 focus:outline-none transition-colors"
1595
+ />
1596
+ <button
1597
+ type="submit"
1598
+ disabled={!apiKey.trim() || isPending}
1599
+ className="inline-flex items-center gap-2 bg-foreground text-background px-5 py-2.5 text-sm font-medium transition-all hover:opacity-90 disabled:opacity-50"
1600
+ >
1601
+ {isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
1602
+ Connect
1603
+ </button>
1604
+ </div>
1605
+
1606
+ {submitError && (
1607
+ <div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
1608
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1609
+ <span>{submitError}</span>
1610
+ </div>
1611
+ )}
1612
+ </div>
1613
+ </form>
1614
+ );
1615
+ }
1616
+
1617
+ /* ── Step 2: Plans ──────────────────────────────────────── */
1618
+
1619
+ interface VerifyData {
1620
+ ready: boolean;
1621
+ totalPlans: number;
1622
+ attached: string[];
1623
+ unattached: string[];
1624
+ plansWithPolpoFeatures: Array<{ id: string; name: string; featureIds: string[] }>;
1625
+ }
1626
+
1627
+ function PlansStep({
1628
+ featureIds,
1629
+ onVerify,
1630
+ verifyPending,
1631
+ verifyError,
1632
+ verifyResult,
1633
+ }: {
1634
+ featureIds: AutumnStatusResponse["featureIds"];
1635
+ onVerify: () => void;
1636
+ verifyPending: boolean;
1637
+ verifyError: string | null;
1638
+ verifyResult: VerifyData | null;
1639
+ }) {
1640
+ return (
1641
+ <div className="flex gap-6">
1642
+ <div className="flex w-[240px] shrink-0 flex-col gap-2">
1643
+ <p className="text-sm font-medium leading-6 text-foreground">Create a plan</p>
1644
+ <p className="text-sm leading-6 text-muted-foreground">
1645
+ Polpo just created 3 features in your Autumn account. Attach{" "}
1646
+ <span className="font-medium text-foreground">at least one</span> of
1647
+ them to <span className="font-medium text-foreground">at least one</span>{" "}
1648
+ plan — that's enough to bridge billing.
1649
+ </p>
1650
+ <a
1651
+ href="https://docs.useautumn.com/products/create-product"
1652
+ target="_blank"
1653
+ rel="noreferrer"
1654
+ className="self-start text-xs font-medium text-foreground hover:underline underline-offset-4"
1655
+ >
1656
+ Plan setup guide →
1657
+ </a>
1658
+ </div>
1659
+ <div className="flex-1 min-w-0 flex flex-col gap-3">
1660
+ {featureIds && <FeatureIdCards featureIds={featureIds} />}
1661
+ {!featureIds && (
1662
+ <div className="border border-border bg-background p-6 text-center text-sm text-muted-foreground">
1663
+ Connect Autumn first to see the feature IDs.
1664
+ </div>
1665
+ )}
1666
+
1667
+ <div className="mt-2 flex flex-wrap items-center gap-3">
1668
+ <a
1669
+ href="https://app.useautumn.com/products?tab=products"
1670
+ target="_blank"
1671
+ rel="noreferrer"
1672
+ className="inline-flex items-center gap-2 bg-foreground text-background px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
1673
+ >
1674
+ Open Autumn Plans
1675
+ <ExternalLink className="h-3.5 w-3.5" />
1676
+ </a>
1677
+ <button
1678
+ type="button"
1679
+ onClick={onVerify}
1680
+ disabled={verifyPending}
1681
+ className="inline-flex items-center gap-2 rounded border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors disabled:opacity-50"
1682
+ >
1683
+ {verifyPending ? (
1684
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1685
+ ) : (
1686
+ <RefreshCw className="h-3.5 w-3.5" />
1687
+ )}
1688
+ Check setup
1689
+ </button>
1690
+ </div>
1691
+
1692
+ {verifyError && (
1693
+ <div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
1694
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1695
+ <span>{verifyError}</span>
1696
+ </div>
1697
+ )}
1698
+
1699
+ {verifyResult && !verifyResult.ready && (
1700
+ <div className="flex items-start gap-2 border border-amber-500/30 bg-amber-500/5 p-2.5 text-xs text-amber-700 dark:text-amber-400">
1701
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1702
+ <div className="min-w-0">
1703
+ <div className="font-medium">No polpo features attached yet</div>
1704
+ <div className="mt-0.5 opacity-80">
1705
+ Found {verifyResult.totalPlans} plan
1706
+ {verifyResult.totalPlans === 1 ? "" : "s"} in your Autumn
1707
+ account, but none of them include a{" "}
1708
+ <code className="font-mono">polpo_*</code> feature. Attach at
1709
+ least one and click Verify again.
1710
+ </div>
1711
+ </div>
1712
+ </div>
1713
+ )}
1714
+
1715
+ {verifyResult && verifyResult.ready && (
1716
+ <div className="flex items-start gap-2 border border-emerald-500/30 bg-emerald-500/5 p-2.5 text-xs text-emerald-700 dark:text-emerald-400">
1717
+ <CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1718
+ <div className="min-w-0">
1719
+ <div className="font-medium">
1720
+ Bridge ready — {verifyResult.attached.length} feature
1721
+ {verifyResult.attached.length === 1 ? "" : "s"} attached
1722
+ </div>
1723
+ {verifyResult.plansWithPolpoFeatures.length > 0 && (
1724
+ <div className="mt-0.5 opacity-80">
1725
+ On{" "}
1726
+ {verifyResult.plansWithPolpoFeatures
1727
+ .map((p) => p.name)
1728
+ .join(", ")}
1729
+ . Move to Step 3 to fire a test event.
1730
+ </div>
1731
+ )}
1732
+ </div>
1733
+ </div>
1734
+ )}
1735
+ </div>
1736
+ </div>
1737
+ );
1738
+ }
1739
+
1740
+ /* ── Step 3: Status / Manage ────────────────────────────── */
1741
+
1742
+ function StatusStep({
1743
+ status,
1744
+ onSendTestEvent,
1745
+ testPending,
1746
+ testError,
1747
+ testResult,
1748
+ }: {
1749
+ status: AutumnStatusResponse | undefined;
1750
+ onSendTestEvent: () => void;
1751
+ testPending: boolean;
1752
+ testError: string | null;
1753
+ testResult: { customerId: string; featureId: string; value: number } | null;
1754
+ }) {
1755
+ return (
1756
+ <div className="flex gap-6">
1757
+ <div className="flex w-[240px] shrink-0 flex-col gap-2">
1758
+ <p className="text-sm font-medium leading-6 text-foreground">Test the bridge</p>
1759
+ <p className="text-sm leading-6 text-muted-foreground">
1760
+ Fire a synthetic event to <span className="font-mono">polpo_completions</span> for{" "}
1761
+ <span className="font-mono">polpo-test-customer</span>. Proves the
1762
+ rail end-to-end before real users hit it.
1763
+ </p>
1764
+ <a
1765
+ href="https://app.useautumn.com/customers"
1766
+ target="_blank"
1767
+ rel="noreferrer"
1768
+ className="self-start text-xs font-medium text-foreground hover:underline underline-offset-4"
1769
+ >
1770
+ Open Autumn customers →
1771
+ </a>
1772
+ </div>
1773
+ <div className="flex-1 min-w-0 flex flex-col gap-3">
1774
+ <div className="grid grid-cols-3 gap-3">
1775
+ <Stat label="Events fired" value={String(status?.eventsCount ?? 0)} />
1776
+ <Stat
1777
+ label="Errors"
1778
+ value={String(status?.errorsCount ?? 0)}
1779
+ tone={status?.errorsCount && status.errorsCount > 0 ? "warn" : "ok"}
1780
+ />
1781
+ <Stat
1782
+ label="Last event"
1783
+ value={status?.lastUsedAt ? formatRelative(status.lastUsedAt) : "—"}
1784
+ />
1785
+ </div>
1786
+
1787
+ <div className="flex flex-wrap items-center gap-3">
1788
+ <button
1789
+ type="button"
1790
+ onClick={onSendTestEvent}
1791
+ disabled={testPending}
1792
+ className="inline-flex items-center gap-2 bg-foreground text-background px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
1793
+ >
1794
+ {testPending ? (
1795
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1796
+ ) : (
1797
+ <Plug className="h-3.5 w-3.5" />
1798
+ )}
1799
+ Send test event
1800
+ </button>
1801
+ </div>
1802
+
1803
+ {testError && (
1804
+ <div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
1805
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1806
+ <span>{testError}</span>
1807
+ </div>
1808
+ )}
1809
+
1810
+ {testResult && (
1811
+ <div className="flex items-start gap-2 border border-emerald-500/30 bg-emerald-500/5 p-2.5 text-xs text-emerald-700 dark:text-emerald-400">
1812
+ <CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1813
+ <div className="min-w-0">
1814
+ <div className="font-medium">Test event delivered</div>
1815
+ <div className="mt-0.5 font-mono opacity-80">
1816
+ {testResult.featureId} += {testResult.value} for{" "}
1817
+ {testResult.customerId}
1818
+ </div>
1819
+ </div>
1820
+ </div>
1821
+ )}
1822
+
1823
+ {status?.lastError && (
1824
+ <div className="flex items-start gap-2 border border-amber-500/30 bg-amber-500/5 p-2.5 text-xs text-amber-700 dark:text-amber-400">
1825
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
1826
+ <div className="min-w-0">
1827
+ <div className="font-medium">Last error</div>
1828
+ <div className="mt-0.5 break-words font-mono text-[11px] opacity-80">
1829
+ {status.lastError}
1830
+ </div>
1831
+ {status.lastErrorAt && (
1832
+ <div className="mt-0.5 text-[11px] opacity-70">
1833
+ {formatRelative(status.lastErrorAt)}
1834
+ </div>
1835
+ )}
1836
+ </div>
1837
+ </div>
1838
+ )}
1839
+
1840
+ {status?.featureIds && <FeatureIdCards featureIds={status.featureIds} />}
1841
+ </div>
1842
+ </div>
1843
+ );
1844
+ }
1845
+
1846
+ /* ── Shared bits ────────────────────────────────────────── */
1847
+
1848
+ /**
1849
+ * Stack of CopyCards — one per polpo_* feature ID. Same visual treatment
1850
+ * as connect-dialog's API key / URL row, swapping the value-list for the
1851
+ * three feature ids the user needs to wire up in Autumn.
1852
+ */
1853
+ function FeatureIdCards({
1854
+ featureIds,
1855
+ }: {
1856
+ featureIds: NonNullable<AutumnStatusResponse["featureIds"]>;
1857
+ }) {
1858
+ return (
1859
+ <div className="flex flex-col gap-2">
1860
+ <CopyCard label="polpo_completions feature" value={featureIds.completions} />
1861
+ <CopyCard label="polpo_tasks feature" value={featureIds.tasks} />
1862
+ <CopyCard label="polpo_inference_usd feature" value={featureIds.inferenceUsd} />
1863
+ </div>
1864
+ );
1865
+ }
1866
+
1867
+ /** Compact stat tile for the integration telemetry panel. */
1868
+ function Stat({ label, value, tone = "ok" }: { label: string; value: string; tone?: "ok" | "warn" }) {
1869
+ return (
1870
+ <div className="border border-border bg-background p-3">
1871
+ <p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
1872
+ {label}
1873
+ </p>
1874
+ <p className={`mt-1 text-lg font-semibold ${tone === "warn" ? "text-amber-600 dark:text-amber-400" : "text-foreground"}`}>
1875
+ {value}
1876
+ </p>
1877
+ </div>
1878
+ );
1879
+ }
1880
+
1881
+ /** Coarse relative-time format — matches the format used elsewhere in the dashboard. */
1882
+ function formatRelative(iso: string): string {
1883
+ const then = new Date(iso).getTime();
1884
+ if (Number.isNaN(then)) return "recently";
1885
+ const diffMs = Date.now() - then;
1886
+ const m = Math.floor(diffMs / 60_000);
1887
+ if (m < 1) return "just now";
1888
+ if (m < 60) return `${m}m ago`;
1889
+ const h = Math.floor(m / 60);
1890
+ if (h < 24) return `${h}h ago`;
1891
+ const d = Math.floor(h / 24);
1892
+ return `${d}d ago`;
1893
+ }
1894
+
1895
+ /* ── Webhooks Tab ─────────────────────────────────────────── */
1896
+
1897
+ function WebhooksTab({ projectId }: { projectId: string }) {
1898
+ const queryClient = useQueryClient();
1899
+ const [showForm, setShowForm] = useState(false);
1900
+ const [formUrl, setFormUrl] = useState("");
1901
+ // Wire format: a Set of event patterns. May contain "*", "task:*", or
1902
+ // specific keys like "task:created". The handlers below maintain the
1903
+ // invariant that wildcards and their children are never both present.
1904
+ const [selectedEvents, setSelectedEvents] = useState<Set<string>>(
1905
+ () => new Set<string>(["*"]),
1906
+ );
1907
+ // Pending webhook deletion — null when no confirm is open. Mirrors the
1908
+ // Autumn disconnect flow so destructive actions always go through a
1909
+ // styled modal instead of window.confirm().
1910
+ const [pendingDelete, setPendingDelete] = useState<Webhook | null>(null);
1911
+ // Webhook currently open in the delivery inspector dialog. Same pattern as
1912
+ // pendingDelete — null means closed.
1913
+ const [pendingInspect, setPendingInspect] = useState<Webhook | null>(null);
1914
+ // When non-null, the form is in "edit" mode for an existing row. The URL
1915
+ // and event selection are pre-filled from `editing` and submit calls
1916
+ // PATCH instead of POST.
1917
+ const [editing, setEditing] = useState<Webhook | null>(null);
1918
+
1919
+ function openCreate() {
1920
+ setEditing(null);
1921
+ setFormUrl("");
1922
+ setSelectedEvents(new Set(["*"]));
1923
+ setShowForm(true);
1924
+ }
1925
+ function openEdit(wh: Webhook) {
1926
+ setEditing(wh);
1927
+ setFormUrl(wh.url);
1928
+ setSelectedEvents(new Set(wh.events));
1929
+ setShowForm(true);
1930
+ }
1931
+ function closeForm() {
1932
+ setShowForm(false);
1933
+ setEditing(null);
1934
+ setFormUrl("");
1935
+ setSelectedEvents(new Set(["*"]));
1936
+ }
1937
+
1938
+ const { data: webhooks = [], isLoading, isFetching, refetch } = useQuery({
1939
+ queryKey: ["webhooks", projectId],
1940
+ queryFn: () =>
1941
+ fetchDataPlane<{ ok: boolean; data: Webhook[] }>(projectId, "/v1/webhooks").then(
1942
+ (r) => r.data ?? []
1943
+ ),
1944
+ });
1945
+
1946
+ // Single mutation that creates *or* updates depending on whether the form
1947
+ // was opened from an existing row. Keeps the success path identical so
1948
+ // the form clears and closes the same way in both flows.
1949
+ const upsertMutation = useMutation({
1950
+ mutationFn: (data: { url: string; events: string[]; editingId: string | null }) =>
1951
+ data.editingId
1952
+ ? mutateDataPlane(projectId, `/v1/webhooks/${data.editingId}`, {
1953
+ method: "PATCH",
1954
+ body: { url: data.url, events: data.events },
1955
+ })
1956
+ : mutateDataPlane(projectId, "/v1/webhooks", {
1957
+ method: "POST",
1958
+ body: { url: data.url, events: data.events },
1959
+ }),
1960
+ onSuccess: () => {
1961
+ queryClient.invalidateQueries({ queryKey: ["webhooks", projectId] });
1962
+ closeForm();
1963
+ },
1964
+ });
1965
+
1966
+ const deleteMutation = useMutation({
1967
+ mutationFn: (webhookId: string) =>
1968
+ mutateDataPlane(projectId, `/v1/webhooks/${webhookId}`, { method: "DELETE" }),
1969
+ onSuccess: () => {
1970
+ queryClient.invalidateQueries({ queryKey: ["webhooks", projectId] });
1971
+ },
1972
+ });
1973
+
1974
+ function handleSubmit(e: React.FormEvent) {
1975
+ e.preventDefault();
1976
+ const events = Array.from(selectedEvents);
1977
+ if (events.length === 0) return;
1978
+ upsertMutation.mutate({
1979
+ url: formUrl.trim(),
1980
+ events,
1981
+ editingId: editing?.id ?? null,
1982
+ });
1983
+ }
1984
+
1985
+ if (isLoading) {
1986
+ return (
1987
+ <div className="space-y-3">
1988
+ {[1, 2].map((i) => (
1989
+ <div key={i} className="h-14 rounded-lg bg-secondary/30 animate-pulse" />
1990
+ ))}
1991
+ </div>
1992
+ );
1993
+ }
1994
+
1995
+ return (
1996
+ <section>
1997
+ <div className="flex items-center justify-between">
1998
+ <div>
1999
+ <h2 className="text-lg font-semibold tracking-tight">Webhooks</h2>
2000
+ <p className="mt-1 text-xs text-muted-foreground">
2001
+ Receive HTTP POST notifications when events occur in this project.
2002
+ </p>
2003
+ </div>
2004
+ <div className="flex items-center gap-3">
2005
+ <button
2006
+ onClick={() => refetch()}
2007
+ disabled={isFetching}
2008
+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
2009
+ >
2010
+ <RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
2011
+ Refresh
2012
+ </button>
2013
+ {!showForm && (
2014
+ <button
2015
+ onClick={openCreate}
2016
+ className="inline-flex items-center gap-2 bg-foreground text-background px-3 py-1.5 text-xs font-medium transition-all hover:opacity-90"
2017
+ >
2018
+ <Plus className="h-3 w-3" />
2019
+ Add webhook
2020
+ </button>
2021
+ )}
2022
+ </div>
2023
+ </div>
2024
+
2025
+ {/* Add form */}
2026
+ {showForm && (
2027
+ <form onSubmit={handleSubmit} className="mt-4 rounded-lg border border-border bg-card p-4 space-y-3">
2028
+ <div>
2029
+ <label className="block text-xs font-medium text-muted-foreground mb-1.5">
2030
+ Endpoint URL
2031
+ </label>
2032
+ <input
2033
+ type="url"
2034
+ value={formUrl}
2035
+ onChange={(e) => setFormUrl(e.target.value)}
2036
+ placeholder="https://example.com/webhook"
2037
+ required
2038
+ className="w-full border border-border bg-transparent px-3 py-2 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors"
2039
+ />
2040
+ </div>
2041
+ <EventSelector
2042
+ selected={selectedEvents}
2043
+ onChange={setSelectedEvents}
2044
+ />
2045
+ {upsertMutation.error && (
2046
+ <p className="text-xs text-destructive">
2047
+ {(upsertMutation.error as Error).message}
2048
+ </p>
2049
+ )}
2050
+ <div className="flex items-center gap-3 pt-1">
2051
+ <button
2052
+ type="submit"
2053
+ disabled={
2054
+ upsertMutation.isPending ||
2055
+ !formUrl.trim() ||
2056
+ selectedEvents.size === 0
2057
+ }
2058
+ className="inline-flex items-center gap-2 bg-foreground text-background px-3 py-1.5 text-xs font-medium transition-all hover:opacity-90 disabled:opacity-50"
2059
+ >
2060
+ {upsertMutation.isPending ? (
2061
+ <Loader2 className="h-3 w-3 animate-spin" />
2062
+ ) : editing ? (
2063
+ "Save changes"
2064
+ ) : (
2065
+ "Create"
2066
+ )}
2067
+ </button>
2068
+ <button
2069
+ type="button"
2070
+ onClick={closeForm}
2071
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
2072
+ >
2073
+ Cancel
2074
+ </button>
2075
+ </div>
2076
+ </form>
2077
+ )}
2078
+
2079
+ {/* Webhooks list */}
2080
+ {webhooks.length > 0 ? (
2081
+ <div className="mt-4 rounded-lg border border-border overflow-hidden">
2082
+ {webhooks.map((wh) => (
2083
+ <div
2084
+ key={wh.id}
2085
+ className="flex items-center justify-between border-b border-border last:border-0 px-4 py-3"
2086
+ >
2087
+ <div className="min-w-0 flex-1">
2088
+ <p className="font-mono text-xs font-medium truncate">{wh.url}</p>
2089
+ <div className="mt-1 flex items-center gap-2">
2090
+ {wh.events.map((ev) => (
2091
+ <span
2092
+ key={ev}
2093
+ className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground"
2094
+ >
2095
+ {ev}
2096
+ </span>
2097
+ ))}
2098
+ </div>
2099
+ </div>
2100
+ <div className="ml-4 flex shrink-0 items-center gap-3">
2101
+ <button
2102
+ onClick={() => setPendingInspect(wh)}
2103
+ className="text-muted-foreground/50 hover:text-foreground transition-colors"
2104
+ aria-label="Inspect deliveries"
2105
+ title="Inspect deliveries"
2106
+ >
2107
+ <Eye className="h-3.5 w-3.5" />
2108
+ </button>
2109
+ <button
2110
+ onClick={() => openEdit(wh)}
2111
+ className="text-muted-foreground/50 hover:text-foreground transition-colors"
2112
+ aria-label="Edit webhook"
2113
+ title="Edit webhook"
2114
+ >
2115
+ <Pencil className="h-3.5 w-3.5" />
2116
+ </button>
2117
+ <button
2118
+ onClick={() => setPendingDelete(wh)}
2119
+ disabled={deleteMutation.isPending}
2120
+ className="text-muted-foreground/50 hover:text-destructive transition-colors"
2121
+ aria-label="Delete webhook"
2122
+ title="Delete webhook"
2123
+ >
2124
+ <Trash2 className="h-3.5 w-3.5" />
2125
+ </button>
2126
+ </div>
2127
+ </div>
2128
+ ))}
2129
+ </div>
2130
+ ) : !showForm ? (
2131
+ <div className="mt-4 rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
2132
+ No webhooks configured.
2133
+ </div>
2134
+ ) : null}
2135
+
2136
+ {/* Confirm-delete modal — same pattern as the Autumn disconnect dialog. */}
2137
+ <Dialog
2138
+ open={!!pendingDelete}
2139
+ onOpenChange={(v) => !v && setPendingDelete(null)}
2140
+ >
2141
+ <DialogContent className="sm:max-w-md bg-card ring-foreground/15">
2142
+ <DialogHeader>
2143
+ <DialogTitle className="text-base">Delete webhook?</DialogTitle>
2144
+ <DialogDescription>
2145
+ <span className="block">
2146
+ Polpo will stop sending events to this endpoint. You can
2147
+ always re-add it.
2148
+ </span>
2149
+ {pendingDelete && (
2150
+ <span className="mt-2 block break-all rounded border border-border bg-secondary/40 px-2 py-1.5 font-mono text-[11px] text-foreground">
2151
+ {pendingDelete.url}
2152
+ </span>
2153
+ )}
2154
+ </DialogDescription>
2155
+ </DialogHeader>
2156
+ <DialogFooter className="gap-2 sm:justify-end">
2157
+ <button
2158
+ type="button"
2159
+ onClick={() => setPendingDelete(null)}
2160
+ className="rounded border border-border px-4 py-2 text-sm hover:border-foreground/30 transition-colors"
2161
+ >
2162
+ Cancel
2163
+ </button>
2164
+ <button
2165
+ type="button"
2166
+ onClick={() => {
2167
+ if (!pendingDelete) return;
2168
+ deleteMutation.mutate(pendingDelete.id);
2169
+ setPendingDelete(null);
2170
+ }}
2171
+ disabled={deleteMutation.isPending}
2172
+ className="inline-flex items-center gap-2 rounded bg-destructive text-destructive-foreground px-4 py-2 text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
2173
+ >
2174
+ {deleteMutation.isPending && (
2175
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
2176
+ )}
2177
+ Delete
2178
+ </button>
2179
+ </DialogFooter>
2180
+ </DialogContent>
2181
+ </Dialog>
2182
+
2183
+ {/* Delivery inspector — opens the new WebhookDeliveries panel. */}
2184
+ <Dialog
2185
+ open={!!pendingInspect}
2186
+ onOpenChange={(v) => !v && setPendingInspect(null)}
2187
+ >
2188
+ <DialogContent className="sm:max-w-3xl bg-card ring-foreground/15">
2189
+ <DialogHeader>
2190
+ <DialogTitle className="text-base">Webhook deliveries</DialogTitle>
2191
+ <DialogDescription>
2192
+ Last 50 attempts. Auto-refreshes every 5s.
2193
+ </DialogDescription>
2194
+ </DialogHeader>
2195
+ {pendingInspect && (
2196
+ <WebhookDeliveries
2197
+ projectId={projectId}
2198
+ webhook={pendingInspect}
2199
+ />
2200
+ )}
2201
+ </DialogContent>
2202
+ </Dialog>
2203
+ </section>
2204
+ );
2205
+ }
2206
+
2207
+ /* ── Webhook event selector (hierarchical multi-checkbox) ─────────── */
2208
+
2209
+ /**
2210
+ * Tree-shaped event picker: a top-level "All events" toggle, then one
2211
+ * collapsible group per namespace (task, mission, agent, …) with a "Select
2212
+ * all" mini-checkbox in its header and individual event checkboxes inside.
2213
+ *
2214
+ * The wire format is just an array of event patterns — the same one the
2215
+ * data plane already accepts (`task:*`, `mission:completed`, `*`, …) — so
2216
+ * we never have to model "expansion" on the backend. The state below
2217
+ * keeps wildcards and their children mutually exclusive, otherwise we'd
2218
+ * end up with confusingly-redundant payloads like `["task:*","task:created"]`.
2219
+ */
2220
+ function EventSelector({
2221
+ selected,
2222
+ onChange,
2223
+ }: {
2224
+ selected: Set<string>;
2225
+ onChange: (next: Set<string>) => void;
2226
+ }) {
2227
+ const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set());
2228
+ // Source of truth lives in `@polpo-ai/core`'s EVENT_CATALOG and is served
2229
+ // by the cloud at `/v1/events/catalog` (cached 1h). Fetching dynamically
2230
+ // keeps the picker in lockstep with whatever the runtime can actually
2231
+ // emit — no more hand-mirrored arrays here.
2232
+ const { data: catalog = [], isLoading } = useEventCatalog();
2233
+ const totalEvents = catalog.reduce((acc, g) => acc + g.events.length, 0);
2234
+ const allChecked = selected.has("*");
2235
+
2236
+ // Count concrete events covered by the current selection — used for the
2237
+ // "X of Y selected" hint and to decide group header tri-state.
2238
+ function countSelectedInGroup(ns: string, events: ReadonlyArray<{ key: string }>) {
2239
+ if (selected.has("*") || selected.has(`${ns}:*`)) return events.length;
2240
+ return events.filter((e) => selected.has(`${ns}:${e.key}`)).length;
2241
+ }
2242
+ const totalSelected = allChecked
2243
+ ? totalEvents
2244
+ : catalog.reduce(
2245
+ (acc, g) => acc + countSelectedInGroup(g.ns, g.events),
2246
+ 0,
2247
+ );
2248
+
2249
+ function toggleAll(next: boolean) {
2250
+ onChange(next ? new Set(["*"]) : new Set());
2251
+ }
2252
+
2253
+ function toggleGroup(ns: string, events: ReadonlyArray<{ key: string }>, next: boolean) {
2254
+ const out = new Set(selected);
2255
+ // Removing the global wildcard is implicit when we move from "everything"
2256
+ // to a finer selection — expand it into the *other* groups so the user
2257
+ // doesn't lose them.
2258
+ if (out.has("*")) {
2259
+ out.delete("*");
2260
+ for (const g of catalog) if (g.ns !== ns) out.add(`${g.ns}:*`);
2261
+ }
2262
+ // Drop any individual children we might have left behind, then set the
2263
+ // group wildcard (or remove it on the off-toggle).
2264
+ for (const e of events) out.delete(`${ns}:${e.key}`);
2265
+ out.delete(`${ns}:*`);
2266
+ if (next) out.add(`${ns}:*`);
2267
+ onChange(out);
2268
+ }
2269
+
2270
+ function toggleEvent(ns: string, key: string, next: boolean) {
2271
+ const out = new Set(selected);
2272
+ // Same expansion logic as toggleGroup, then expand the group's own
2273
+ // wildcard into per-event entries so we can remove just one.
2274
+ if (out.has("*")) {
2275
+ out.delete("*");
2276
+ for (const g of catalog) if (g.ns !== ns) out.add(`${g.ns}:*`);
2277
+ }
2278
+ if (out.has(`${ns}:*`)) {
2279
+ out.delete(`${ns}:*`);
2280
+ const group = catalog.find((g) => g.ns === ns);
2281
+ if (group) for (const e of group.events) out.add(`${ns}:${e.key}`);
2282
+ }
2283
+ const fq = `${ns}:${key}`;
2284
+ if (next) out.add(fq);
2285
+ else out.delete(fq);
2286
+ onChange(out);
2287
+ }
2288
+
2289
+ function toggleOpen(ns: string) {
2290
+ const next = new Set(openGroups);
2291
+ if (next.has(ns)) next.delete(ns);
2292
+ else next.add(ns);
2293
+ setOpenGroups(next);
2294
+ }
2295
+
2296
+ return (
2297
+ <div>
2298
+ <div className="mb-1.5 flex items-baseline justify-between">
2299
+ <label className="block text-xs font-medium text-muted-foreground">
2300
+ Events
2301
+ </label>
2302
+ <span className="text-[11px] text-muted-foreground">
2303
+ {totalSelected} of {totalEvents} selected
2304
+ </span>
2305
+ </div>
2306
+
2307
+ <div className="border border-border">
2308
+ {/* All events */}
2309
+ <label className="flex cursor-pointer items-center gap-3 border-b border-border px-3 py-2.5 transition-colors hover:bg-secondary/40">
2310
+ <Checkbox checked={allChecked} indeterminate={false} />
2311
+ <div className="min-w-0 flex-1">
2312
+ <p className="text-sm font-medium leading-none">All events</p>
2313
+ <p className="mt-1 text-[11px] text-muted-foreground">
2314
+ Receive every event Polpo emits — overrides the per-namespace
2315
+ selection below.
2316
+ </p>
2317
+ </div>
2318
+ <input
2319
+ type="checkbox"
2320
+ checked={allChecked}
2321
+ onChange={(e) => toggleAll(e.target.checked)}
2322
+ className="sr-only"
2323
+ aria-label="All events"
2324
+ />
2325
+ </label>
2326
+
2327
+ {/* Namespace groups */}
2328
+ <div className="max-h-[320px] overflow-y-auto">
2329
+ {isLoading && catalog.length === 0 && (
2330
+ <div className="px-3 py-6 text-center text-xs text-muted-foreground">
2331
+ Loading event catalog…
2332
+ </div>
2333
+ )}
2334
+ {catalog.map((group) => {
2335
+ const isOpen = openGroups.has(group.ns);
2336
+ const groupCount = countSelectedInGroup(group.ns, group.events);
2337
+ const groupAll = groupCount === group.events.length && groupCount > 0;
2338
+ const groupSome = groupCount > 0 && !groupAll;
2339
+ const Chevron = isOpen ? ChevronDown : ChevronRight;
2340
+ return (
2341
+ <div key={group.ns} className="border-b border-border last:border-b-0">
2342
+ <div className="flex items-center gap-2 px-3 py-2">
2343
+ <button
2344
+ type="button"
2345
+ onClick={() => toggleOpen(group.ns)}
2346
+ className="inline-flex items-center text-muted-foreground hover:text-foreground"
2347
+ aria-label={isOpen ? "Collapse" : "Expand"}
2348
+ >
2349
+ <Chevron className="h-3.5 w-3.5" strokeWidth={1.5} />
2350
+ </button>
2351
+
2352
+ <label className="flex flex-1 cursor-pointer items-center gap-3">
2353
+ <Checkbox
2354
+ checked={groupAll || allChecked}
2355
+ indeterminate={!allChecked && groupSome}
2356
+ />
2357
+ <div className="min-w-0 flex-1">
2358
+ <p className="text-sm font-medium leading-none">
2359
+ {group.label}
2360
+ <span className="ml-2 font-mono text-[10px] font-normal text-muted-foreground">
2361
+ {group.ns}:*
2362
+ </span>
2363
+ </p>
2364
+ <p className="mt-1 text-[11px] text-muted-foreground">
2365
+ {group.description}
2366
+ </p>
2367
+ </div>
2368
+ <span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
2369
+ {allChecked
2370
+ ? `${group.events.length}/${group.events.length}`
2371
+ : `${groupCount}/${group.events.length}`}
2372
+ </span>
2373
+ <input
2374
+ type="checkbox"
2375
+ checked={groupAll || allChecked}
2376
+ onChange={(e) =>
2377
+ toggleGroup(group.ns, group.events, e.target.checked)
2378
+ }
2379
+ disabled={allChecked}
2380
+ className="sr-only"
2381
+ aria-label={`Select all ${group.label} events`}
2382
+ />
2383
+ </label>
2384
+ </div>
2385
+
2386
+ {isOpen && (
2387
+ <div className="grid grid-cols-1 gap-y-1 border-t border-border bg-secondary/20 px-3 py-2 sm:grid-cols-2">
2388
+ {group.events.map((ev) => {
2389
+ const checked =
2390
+ allChecked ||
2391
+ selected.has(`${group.ns}:*`) ||
2392
+ selected.has(`${group.ns}:${ev.key}`);
2393
+ return (
2394
+ <label
2395
+ key={ev.key}
2396
+ className="flex cursor-pointer items-start gap-2 px-1.5 py-1.5 text-xs hover:bg-secondary/60"
2397
+ title={ev.description}
2398
+ >
2399
+ <Checkbox checked={checked} indeterminate={false} compact />
2400
+ <span className="min-w-0 flex-1">
2401
+ <span className="block font-mono text-[12px] text-foreground">
2402
+ {group.ns}:{ev.key}
2403
+ </span>
2404
+ <span className="block truncate text-[11px] text-muted-foreground">
2405
+ {ev.description}
2406
+ </span>
2407
+ </span>
2408
+ <input
2409
+ type="checkbox"
2410
+ checked={checked}
2411
+ onChange={(e) =>
2412
+ toggleEvent(group.ns, ev.key, e.target.checked)
2413
+ }
2414
+ className="sr-only"
2415
+ />
2416
+ </label>
2417
+ );
2418
+ })}
2419
+ </div>
2420
+ )}
2421
+ </div>
2422
+ );
2423
+ })}
2424
+ </div>
2425
+ </div>
2426
+
2427
+ {totalSelected === 0 && (
2428
+ <p className="mt-1.5 text-[11px] text-amber-600 dark:text-amber-400">
2429
+ Select at least one event to receive notifications.
2430
+ </p>
2431
+ )}
2432
+ </div>
2433
+ );
2434
+ }
2435
+
2436
+ /**
2437
+ * Minimal styled checkbox — supports tri-state (`indeterminate`) for the
2438
+ * group headers when a subset of children is selected. We render a custom
2439
+ * box because native indeterminate styling is browser-dependent and ugly.
2440
+ */
2441
+ function Checkbox({
2442
+ checked,
2443
+ indeterminate,
2444
+ compact,
2445
+ }: {
2446
+ checked: boolean;
2447
+ indeterminate: boolean;
2448
+ compact?: boolean;
2449
+ }) {
2450
+ const size = compact ? "h-3.5 w-3.5" : "h-4 w-4";
2451
+ if (indeterminate) {
2452
+ return (
2453
+ <span
2454
+ aria-hidden
2455
+ className={`inline-flex shrink-0 items-center justify-center border ${size} border-foreground bg-foreground/10 text-foreground`}
2456
+ >
2457
+ <Minus className="h-2.5 w-2.5" strokeWidth={3} />
2458
+ </span>
2459
+ );
2460
+ }
2461
+ if (checked) {
2462
+ return (
2463
+ <span
2464
+ aria-hidden
2465
+ className={`inline-flex shrink-0 items-center justify-center border ${size} border-foreground bg-foreground text-background`}
2466
+ >
2467
+ <Check className="h-2.5 w-2.5" strokeWidth={3} />
2468
+ </span>
2469
+ );
2470
+ }
2471
+ return (
2472
+ <span
2473
+ aria-hidden
2474
+ className={`inline-flex shrink-0 ${size} border border-border bg-background`}
2475
+ />
2476
+ );
2477
+ }