@growthub/cli 0.13.5 → 0.13.6

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 (35) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/QUICKSTART.md +19 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/.env.example +8 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +4 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/action/execute/route.js +60 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/actions/route.js +50 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/connect-session/route.js +68 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/connection-status/route.js +56 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/proxy/route.js +67 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/status/route.js +50 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +161 -50
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/NangoConnectionPanel.jsx +496 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +104 -17
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/nango/page.jsx +167 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +1 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +18 -7
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +17 -9
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +16 -14
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/env.js +7 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/nango/index.js +38 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/nango/nango-adapter.js +552 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/nango/nango-config-loader.js +202 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/nango/nango-schema.js +303 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +49 -10
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +1 -1
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/nango.js +49 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/template-registry.js +4 -2
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +2 -2
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +2 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +102 -3
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/bundles/growthub-custom-workspace-starter-v1.json +1 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +2 -0
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/project-management.config.json +276 -0
  34. package/dist/index.js +127 -44
  35. package/package.json +1 -1
@@ -0,0 +1,496 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { CheckCircle, ExternalLink, Loader2, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
5
+
6
+ /**
7
+ * NangoConnectionPanel — interactive sidecar for an api-registry row whose
8
+ * `connectorKind === "nango"`. Aligns with Nango's documented OAuth
9
+ * lifecycle:
10
+ *
11
+ * 1. Create Connect Session — needs `providerConfigKey` only. Nango
12
+ * mints a session token + connect_link. The user opens the link and
13
+ * completes OAuth on the provider.
14
+ * 2. Nango generates the `connectionId` server-side and delivers it via
15
+ * the auth webhook (which an operator can persist into the row's
16
+ * `connectionIds` later — webhook persistence is a follow-up).
17
+ * 3. The user pastes the `connectionId` shown in Nango Cloud / received
18
+ * via webhook into the panel; "Check Connection" then verifies it.
19
+ * 4. Reconnect uses the existing `connectionId` as input and produces a
20
+ * fresh authorization session for the same connection.
21
+ *
22
+ * The panel reads `providerConfigKey`, `integrationId`, and `connectionIds`
23
+ * off the row (with sensible fallbacks) and never persists secrets. It
24
+ * calls server routes which themselves go through `@nangohq/node`. The
25
+ * browser never sees the Nango secret key or any provider OAuth credential.
26
+ *
27
+ * Props:
28
+ * row — the api-registry row being edited
29
+ * disabled — disable all controls (typically when the parent is saving)
30
+ * onUpdateRow(patch) — optional: parent applies patch (e.g. status:
31
+ * "connected", lastTested timestamp) when verification succeeds
32
+ */
33
+
34
+ const POLL_INTERVAL_MS = 3000;
35
+ const POLL_DURATION_MS = 60000;
36
+
37
+ function deriveProviderConfigKey(row) {
38
+ if (typeof row?.providerConfigKey === "string" && row.providerConfigKey.trim()) {
39
+ return row.providerConfigKey.trim();
40
+ }
41
+ if (typeof row?.integrationId === "string" && row.integrationId.trim()) {
42
+ return row.integrationId.trim();
43
+ }
44
+ return "";
45
+ }
46
+
47
+ function deriveDefaultConnectionId(row) {
48
+ if (Array.isArray(row?.connectionIds) && row.connectionIds.length) {
49
+ return String(row.connectionIds[0]).trim();
50
+ }
51
+ if (typeof row?.connectionIds === "string" && row.connectionIds.trim()) {
52
+ return row.connectionIds.split(",").map((c) => c.trim()).filter(Boolean)[0] || "";
53
+ }
54
+ if (typeof row?.connectionId === "string" && row.connectionId.trim()) {
55
+ return row.connectionId.trim();
56
+ }
57
+ return "";
58
+ }
59
+
60
+ function deriveInitialStatus(row) {
61
+ const status = typeof row?.status === "string" ? row.status.trim().toLowerCase() : "";
62
+ if (status === "connected") {
63
+ return {
64
+ kind: "connected",
65
+ message: row?.lastTested
66
+ ? `Connected · last verified ${formatRelativeTime(row.lastTested)}`
67
+ : "Connected"
68
+ };
69
+ }
70
+ if (status === "failed" || status === "error") {
71
+ return { kind: "error", message: "Last verification failed. Re-check the connection." };
72
+ }
73
+ return { kind: "unknown", message: "" };
74
+ }
75
+
76
+ function formatRelativeTime(isoString) {
77
+ const ts = new Date(isoString);
78
+ if (Number.isNaN(ts.getTime())) return "recently";
79
+ const diffMs = Date.now() - ts.getTime();
80
+ if (diffMs < 60_000) return "just now";
81
+ if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
82
+ if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
83
+ return `${Math.floor(diffMs / 86_400_000)}d ago`;
84
+ }
85
+
86
+ function validateProviderConfigKey(value) {
87
+ if (!value) return "providerConfigKey is required (the Nango integration key).";
88
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(value)) {
89
+ return "Use letters, digits, _, ., or - (max 64 chars, starts alphanumeric).";
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function validateConnectionIdField(value, { required }) {
95
+ if (!value) return required ? "connectionId is required to verify or reconnect." : null;
96
+ if (value.length > 256) return "connectionId is too long (max 256 chars).";
97
+ return null;
98
+ }
99
+
100
+ function StatusBadge({ status, label }) {
101
+ const map = {
102
+ connected: { className: "dm-api-action-card-status connected", Icon: CheckCircle },
103
+ "not-connected": { className: "dm-api-action-card-status warn", Icon: XCircle },
104
+ error: { className: "dm-api-action-card-status error", Icon: XCircle },
105
+ pending: { className: "dm-api-action-card-status pending", Icon: Loader2 },
106
+ unknown: { className: "dm-api-action-card-status", Icon: ShieldCheck }
107
+ };
108
+ const { className, Icon } = map[status] || map.unknown;
109
+ return (
110
+ <span className={className} role="status" aria-live="polite">
111
+ <Icon size={14} aria-hidden="true" />
112
+ <span>{label || status}</span>
113
+ </span>
114
+ );
115
+ }
116
+
117
+ export function NangoConnectionPanel({ row, disabled, onUpdateRow }) {
118
+ const initialProviderConfigKey = useMemo(() => deriveProviderConfigKey(row), [row]);
119
+ const initialConnectionId = useMemo(() => deriveDefaultConnectionId(row), [row]);
120
+ const initialStatus = useMemo(() => deriveInitialStatus(row), [row]);
121
+ const lastTested = typeof row?.lastTested === "string" ? row.lastTested : "";
122
+ const persistedStatus = typeof row?.status === "string" ? row.status.trim().toLowerCase() : "";
123
+
124
+ const [providerConfigKey, setProviderConfigKey] = useState(initialProviderConfigKey);
125
+ const [connectionId, setConnectionId] = useState(initialConnectionId);
126
+ const [fieldErrors, setFieldErrors] = useState({});
127
+ const [creatingSession, setCreatingSession] = useState(false);
128
+ const [checkingConnection, setCheckingConnection] = useState(false);
129
+ const [polling, setPolling] = useState(false);
130
+ const [statusKind, setStatusKind] = useState(initialStatus.kind);
131
+ const [statusMessage, setStatusMessage] = useState(initialStatus.message);
132
+ const [connectLink, setConnectLink] = useState(null);
133
+ const [sessionMode, setSessionMode] = useState(null);
134
+ const [lastSummary, setLastSummary] = useState(null);
135
+ const [errorRecovery, setErrorRecovery] = useState(null);
136
+
137
+ const pollTimerRef = useRef(null);
138
+ const pollDeadlineRef = useRef(0);
139
+
140
+ useEffect(() => {
141
+ setProviderConfigKey(initialProviderConfigKey);
142
+ setConnectionId(initialConnectionId);
143
+ setFieldErrors({});
144
+ setConnectLink(null);
145
+ setSessionMode(null);
146
+ setLastSummary(null);
147
+ setErrorRecovery(null);
148
+ setStatusKind(initialStatus.kind);
149
+ setStatusMessage(initialStatus.message);
150
+ setPolling(false);
151
+ if (pollTimerRef.current) {
152
+ clearTimeout(pollTimerRef.current);
153
+ pollTimerRef.current = null;
154
+ }
155
+ }, [row?.id, row?.integrationId, initialProviderConfigKey, initialConnectionId, initialStatus.kind, initialStatus.message]);
156
+
157
+ useEffect(() => () => {
158
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
159
+ }, []);
160
+
161
+ const handleProviderConfigKeyBlur = useCallback((value) => {
162
+ setFieldErrors((prev) => ({ ...prev, providerConfigKey: validateProviderConfigKey(value.trim()) }));
163
+ }, []);
164
+
165
+ const handleConnectionIdBlur = useCallback((value) => {
166
+ setFieldErrors((prev) => ({ ...prev, connectionId: validateConnectionIdField(value.trim(), { required: false }) }));
167
+ }, []);
168
+
169
+ const runStatusCheck = useCallback(async (silent = false) => {
170
+ const connectionIdValue = connectionId.trim();
171
+ const connectionIdError = validateConnectionIdField(connectionIdValue, { required: true });
172
+ if (connectionIdError) {
173
+ setFieldErrors((prev) => ({ ...prev, connectionId: connectionIdError }));
174
+ return { ok: false, missingConnectionId: true };
175
+ }
176
+ if (!silent) setCheckingConnection(true);
177
+ setErrorRecovery(null);
178
+ try {
179
+ const response = await fetch("/api/workspace/integrations/nango/connection-status", {
180
+ method: "POST",
181
+ headers: { "content-type": "application/json" },
182
+ body: JSON.stringify({
183
+ providerConfigKey: providerConfigKey.trim(),
184
+ connectionId: connectionIdValue
185
+ })
186
+ });
187
+ const payload = await response.json();
188
+ if (!response.ok || !payload?.ok) {
189
+ const message = payload?.error || `HTTP ${response.status}`;
190
+ setStatusKind("error");
191
+ setStatusMessage(message);
192
+ setErrorRecovery({
193
+ message,
194
+ code: payload?.code || null,
195
+ hint: payload?.code === "NANGO_NOT_CONFIGURED"
196
+ ? "Set NANGO_SECRET_KEY in this runtime to enable Nango."
197
+ : payload?.code === "NANGO_SDK_UNAVAILABLE"
198
+ ? "Install @nangohq/node in apps/workspace."
199
+ : null
200
+ });
201
+ return { ok: false };
202
+ }
203
+ setLastSummary(payload);
204
+ if (payload.status === "connected") {
205
+ setStatusKind("connected");
206
+ setStatusMessage(`Connected · environment: ${payload.environment || "dev"}`);
207
+ if (typeof onUpdateRow === "function") {
208
+ onUpdateRow({
209
+ status: "connected",
210
+ lastTested: new Date().toISOString(),
211
+ lastResponse: JSON.stringify({ connection: payload.connection || {} })
212
+ });
213
+ }
214
+ return { ok: true, connected: true };
215
+ }
216
+ setStatusKind("not-connected");
217
+ setStatusMessage(payload.reason || "Not connected yet.");
218
+ return { ok: true, connected: false };
219
+ } catch (error) {
220
+ const message = error?.message || "network error";
221
+ setStatusKind("error");
222
+ setStatusMessage(message);
223
+ setErrorRecovery({ message, code: null, hint: null });
224
+ return { ok: false };
225
+ } finally {
226
+ if (!silent) setCheckingConnection(false);
227
+ }
228
+ }, [providerConfigKey, connectionId, onUpdateRow]);
229
+
230
+ const startPolling = useCallback(() => {
231
+ if (!connectionId.trim()) {
232
+ // Without a connectionId we can't poll — the user must paste it once
233
+ // Nango delivers it (via Connect UI or the auth webhook).
234
+ return;
235
+ }
236
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
237
+ pollDeadlineRef.current = Date.now() + POLL_DURATION_MS;
238
+ setPolling(true);
239
+ const tick = async () => {
240
+ if (Date.now() > pollDeadlineRef.current) {
241
+ setPolling(false);
242
+ if (statusKind !== "connected") {
243
+ setStatusKind("not-connected");
244
+ setStatusMessage("Still waiting on Nango Connect — click Check Connection when you've finished OAuth.");
245
+ }
246
+ return;
247
+ }
248
+ const result = await runStatusCheck(true);
249
+ if (result?.connected) {
250
+ setPolling(false);
251
+ return;
252
+ }
253
+ pollTimerRef.current = setTimeout(tick, POLL_INTERVAL_MS);
254
+ };
255
+ pollTimerRef.current = setTimeout(tick, POLL_INTERVAL_MS);
256
+ }, [runStatusCheck, statusKind, connectionId]);
257
+
258
+ const createSession = useCallback(async ({ reconnect }) => {
259
+ const providerError = validateProviderConfigKey(providerConfigKey.trim());
260
+ const connectionIdError = reconnect
261
+ ? validateConnectionIdField(connectionId.trim(), { required: true })
262
+ : null;
263
+ setFieldErrors((prev) => ({ ...prev, providerConfigKey: providerError, connectionId: connectionIdError }));
264
+ if (providerError || connectionIdError) return;
265
+
266
+ setCreatingSession(true);
267
+ setConnectLink(null);
268
+ setSessionMode(null);
269
+ setErrorRecovery(null);
270
+ setStatusKind("pending");
271
+ setStatusMessage(reconnect ? "Creating Nango Reconnect session…" : "Creating Nango Connect session…");
272
+
273
+ // Build correlation tags so the auth webhook can identify which row
274
+ // asked for OAuth. No secrets — only stable identifiers.
275
+ const tags = {};
276
+ if (typeof row?.id === "string" && row.id.trim()) tags.row_id = row.id.trim();
277
+ if (typeof row?.integrationId === "string" && row.integrationId.trim()) tags.integration_id = row.integrationId.trim();
278
+ if (typeof row?.objectId === "string" && row.objectId.trim()) tags.object_id = row.objectId.trim();
279
+
280
+ try {
281
+ const response = await fetch("/api/workspace/integrations/nango/connect-session", {
282
+ method: "POST",
283
+ headers: { "content-type": "application/json" },
284
+ body: JSON.stringify({
285
+ providerConfigKey: providerConfigKey.trim(),
286
+ ...(reconnect ? { connectionId: connectionId.trim(), reconnect: true } : {}),
287
+ tags
288
+ })
289
+ });
290
+ const payload = await response.json();
291
+ if (!response.ok || !payload?.ok) {
292
+ const message = payload?.error || `HTTP ${response.status}`;
293
+ setStatusKind("error");
294
+ setStatusMessage(message);
295
+ setErrorRecovery({
296
+ message,
297
+ code: payload?.code || null,
298
+ hint: payload?.code === "NANGO_NOT_CONFIGURED"
299
+ ? "Set NANGO_SECRET_KEY in this runtime to enable Nango."
300
+ : payload?.code === "NANGO_SDK_UNAVAILABLE"
301
+ ? "Install @nangohq/node in apps/workspace."
302
+ : null
303
+ });
304
+ return;
305
+ }
306
+ if (!payload.connectLink) {
307
+ setStatusKind("error");
308
+ setStatusMessage("Nango returned a session without a connect_link. Check your Nango Cloud setup.");
309
+ return;
310
+ }
311
+ setConnectLink(payload.connectLink);
312
+ setSessionMode(payload.mode || (reconnect ? "reconnect" : "connect"));
313
+ setStatusKind("pending");
314
+ setStatusMessage(
315
+ reconnect
316
+ ? "Reconnect link ready — complete OAuth, then we'll re-verify automatically."
317
+ : "Open the Connect link to finish OAuth. Nango will return a connectionId — paste it below or wait for the auth webhook to persist it."
318
+ );
319
+ try {
320
+ const win = window.open(payload.connectLink, "_blank", "noopener,noreferrer");
321
+ if (win) win.focus();
322
+ } catch {
323
+ // window.open may be blocked silently — the visible link below still works.
324
+ }
325
+ // Auto-poll only when we already have a connectionId (reconnect flow,
326
+ // or the user pre-filled it for a known existing connection).
327
+ if (connectionId.trim()) {
328
+ startPolling();
329
+ }
330
+ } catch (error) {
331
+ const message = error?.message || "network error";
332
+ setStatusKind("error");
333
+ setStatusMessage(message);
334
+ setErrorRecovery({ message, code: null, hint: null });
335
+ } finally {
336
+ setCreatingSession(false);
337
+ }
338
+ }, [providerConfigKey, connectionId, row, startPolling]);
339
+
340
+ const handleCreateSession = useCallback(() => createSession({ reconnect: false }), [createSession]);
341
+ const handleReconnect = useCallback(() => createSession({ reconnect: true }), [createSession]);
342
+
343
+ const handleDisconnect = useCallback(() => {
344
+ setConnectLink(null);
345
+ setSessionMode(null);
346
+ setLastSummary(null);
347
+ setStatusKind("unknown");
348
+ setStatusMessage("");
349
+ setPolling(false);
350
+ if (pollTimerRef.current) {
351
+ clearTimeout(pollTimerRef.current);
352
+ pollTimerRef.current = null;
353
+ }
354
+ if (typeof onUpdateRow === "function") {
355
+ onUpdateRow({ status: "configured", lastResponse: "" });
356
+ }
357
+ }, [onUpdateRow]);
358
+
359
+ const isBusy = creatingSession || checkingConnection || polling;
360
+ const hasProviderError = Boolean(fieldErrors.providerConfigKey);
361
+ const hasConnectionIdError = Boolean(fieldErrors.connectionId);
362
+ const hasConnectionId = Boolean(connectionId.trim());
363
+
364
+ return (
365
+ <section className="dm-api-action-card" aria-label="Nango connection">
366
+ <div className="dm-api-action-card-body">
367
+ <p className="dm-api-action-card-eyebrow">Nango</p>
368
+ <h3>Connect this API through Nango</h3>
369
+ <p>
370
+ Nango handles OAuth and API authentication for hundreds of providers. Your provider credentials stay on Nango — this workspace only sees safe connection metadata, never tokens.
371
+ </p>
372
+
373
+ <ol className="dm-nango-steps">
374
+ <li>Enter the <strong>providerConfigKey</strong> (the Nango integration key).</li>
375
+ <li>Click <strong>Create Connect Session</strong> and complete OAuth in the new tab.</li>
376
+ <li>Paste the <strong>connectionId</strong> Nango shows you (or wait for the auth webhook to persist it), then click <strong>Check Connection</strong>.</li>
377
+ </ol>
378
+
379
+ <div className="dm-nango-fields">
380
+ <label className="dm-field">
381
+ <span>providerConfigKey</span>
382
+ <input
383
+ type="text"
384
+ value={providerConfigKey}
385
+ onChange={(event) => setProviderConfigKey(event.target.value)}
386
+ onBlur={(event) => handleProviderConfigKeyBlur(event.target.value)}
387
+ placeholder="e.g. hubspot-prod"
388
+ disabled={disabled || isBusy}
389
+ aria-invalid={hasProviderError}
390
+ aria-describedby={hasProviderError ? "nango-pck-error" : undefined}
391
+ />
392
+ {hasProviderError
393
+ ? <small id="nango-pck-error" className="dm-field-error">{fieldErrors.providerConfigKey}</small>
394
+ : <small className="dm-field-hint">Defaults to <code>integrationId</code> when blank.</small>}
395
+ </label>
396
+
397
+ <label className="dm-field">
398
+ <span>connectionId <small>(required to verify or reconnect)</small></span>
399
+ <input
400
+ type="text"
401
+ value={connectionId}
402
+ onChange={(event) => setConnectionId(event.target.value)}
403
+ onBlur={(event) => handleConnectionIdBlur(event.target.value)}
404
+ placeholder="Nango returns this after OAuth"
405
+ disabled={disabled || isBusy}
406
+ aria-invalid={hasConnectionIdError}
407
+ aria-describedby={hasConnectionIdError ? "nango-cid-error" : undefined}
408
+ />
409
+ {hasConnectionIdError
410
+ ? <small id="nango-cid-error" className="dm-field-error">{fieldErrors.connectionId}</small>
411
+ : <small className="dm-field-hint">Nango generates this during OAuth and delivers it via auth webhook.</small>}
412
+ </label>
413
+ </div>
414
+
415
+ <StatusBadge status={statusKind} label={statusMessage || statusKind} />
416
+
417
+ {persistedStatus === "connected" && lastTested ? (
418
+ <p className="dm-nango-last-tested" aria-label="Last connection verification">
419
+ Last verified <time dateTime={lastTested}>{formatRelativeTime(lastTested)}</time>
420
+ </p>
421
+ ) : null}
422
+
423
+ {connectLink ? (
424
+ <p className="dm-nango-connect-link">
425
+ <a href={connectLink} target="_blank" rel="noopener noreferrer">
426
+ <ExternalLink size={14} aria-hidden="true" />
427
+ {sessionMode === "reconnect" ? " Reopen Nango Reconnect link" : " Reopen Nango Connect link"}
428
+ </a>
429
+ </p>
430
+ ) : null}
431
+
432
+ {errorRecovery ? (
433
+ <div className="dm-nango-error-recovery" role="alert">
434
+ <p>{errorRecovery.message}</p>
435
+ {errorRecovery.hint ? <p className="dm-nango-error-hint">{errorRecovery.hint}</p> : null}
436
+ <button
437
+ type="button"
438
+ className="dm-btn-outline"
439
+ onClick={() => runStatusCheck(false)}
440
+ disabled={disabled || isBusy || hasProviderError}
441
+ >
442
+ <RefreshCw size={14} aria-hidden="true" /> Try Again
443
+ </button>
444
+ </div>
445
+ ) : null}
446
+ </div>
447
+
448
+ <div className="dm-api-action-card-actions">
449
+ <button
450
+ type="button"
451
+ className="dm-btn-primary-sm dm-api-action-card-cta"
452
+ onClick={handleCreateSession}
453
+ disabled={disabled || isBusy || hasProviderError}
454
+ >
455
+ {creatingSession && sessionMode !== "reconnect"
456
+ ? <><Loader2 className="dm-spinner" size={14} aria-hidden="true" /> Creating session…</>
457
+ : "Create Connect Session"}
458
+ </button>
459
+ {hasConnectionId ? (
460
+ <button
461
+ type="button"
462
+ className="dm-btn-outline dm-api-action-card-cta"
463
+ onClick={handleReconnect}
464
+ disabled={disabled || isBusy || hasProviderError || hasConnectionIdError}
465
+ >
466
+ {creatingSession && sessionMode === "reconnect"
467
+ ? <><Loader2 className="dm-spinner" size={14} aria-hidden="true" /> Reconnecting…</>
468
+ : "Reconnect"}
469
+ </button>
470
+ ) : null}
471
+ <button
472
+ type="button"
473
+ className="dm-btn-outline dm-api-action-card-cta"
474
+ onClick={() => runStatusCheck(false)}
475
+ disabled={disabled || isBusy || hasProviderError}
476
+ >
477
+ {checkingConnection
478
+ ? <><Loader2 className="dm-spinner" size={14} aria-hidden="true" /> Checking…</>
479
+ : polling
480
+ ? <><Loader2 className="dm-spinner" size={14} aria-hidden="true" /> Auto-polling…</>
481
+ : "Check Connection"}
482
+ </button>
483
+ {(statusKind === "connected" || persistedStatus === "connected") ? (
484
+ <button
485
+ type="button"
486
+ className="dm-btn-outline dm-api-action-card-cta"
487
+ onClick={handleDisconnect}
488
+ disabled={disabled || isBusy}
489
+ >
490
+ Reset
491
+ </button>
492
+ ) : null}
493
+ </div>
494
+ </section>
495
+ );
496
+ }
@@ -396,6 +396,7 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
396
396
  }
397
397
 
398
398
  .workspace-builder {
399
+ --workspace-rail-width: 264px;
399
400
  min-height: 100vh;
400
401
  display: grid;
401
402
  grid-template-columns: 264px minmax(0, 1fr) 320px;
@@ -427,6 +428,7 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
427
428
  overflow: hidden;
428
429
  }
429
430
  body.workspace-rail-collapsed .workspace-builder {
431
+ --workspace-rail-width: 52px;
430
432
  grid-template-columns: 52px minmax(0, 1fr) 320px;
431
433
  }
432
434
  body.workspace-rail-collapsed .workspace-builder.workspace-settings-page {
@@ -456,24 +458,19 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
456
458
  .workspace-rail.is-collapsed .workspace-rail-topbar {
457
459
  justify-content: center;
458
460
  width: 100%;
461
+ display: contents;
459
462
  }
460
463
  .workspace-rail.is-collapsed .workspace-rail-topbar-actions {
461
- display: flex;
462
- flex-direction: column;
463
- justify-content: center;
464
- gap: 8px;
465
- }
466
- .workspace-rail.is-collapsed .workspace-rail-topbar-actions .workspace-rail-icon-btn {
467
- order: 2;
464
+ display: contents;
468
465
  }
469
466
  .workspace-rail.is-collapsed .workspace-rail-icon-btn[aria-pressed="true"] {
470
467
  order: 1;
471
468
  }
472
469
  .workspace-rail.is-collapsed .workspace-rail-icon-btn[data-rail-search] {
473
- order: 2;
470
+ order: 3;
474
471
  }
475
472
  .workspace-rail.is-collapsed .workspace-rail-icon-btn[aria-label="Workspace settings"] {
476
- order: 3;
473
+ order: 4;
477
474
  }
478
475
  .workspace-rail.is-collapsed .workspace-rail-icon-btn[aria-pressed="true"] svg {
479
476
  transform: rotate(180deg);
@@ -482,6 +479,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
482
479
  display: flex;
483
480
  justify-content: center;
484
481
  width: 100%;
482
+ order: 2;
485
483
  }
486
484
  .workspace-rail.is-collapsed .workspace-rail-tabs {
487
485
  flex-direction: column;
@@ -2128,6 +2126,63 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
2128
2126
  transition: opacity .12s ease;
2129
2127
  }
2130
2128
 
2129
+ .workspace-dashboard-surface {
2130
+ background: #f7f7f8;
2131
+ padding: 0 32px 24px;
2132
+ }
2133
+ .workspace-dashboard-surface .dm-workflow-toolbar {
2134
+ margin: 0 0 18px;
2135
+ padding: 0;
2136
+ background: #ffffff;
2137
+ border-bottom-color: #e8e8e8;
2138
+ }
2139
+ .workspace-dashboard-surface .workspace-canvas {
2140
+ width: 100%;
2141
+ margin: 0 auto;
2142
+ border: 1px solid #e5e7eb;
2143
+ border-radius: 8px;
2144
+ background: #f7f7f8;
2145
+ max-height: calc(100vh - 72px);
2146
+ }
2147
+ .workspace-dashboard-surface .workspace-tabs {
2148
+ gap: 6px;
2149
+ min-height: 44px;
2150
+ padding: 8px 12px 12px;
2151
+ background: #ffffff;
2152
+ border-bottom: 1px solid #ececec;
2153
+ border-radius: 8px 8px 0 0;
2154
+ }
2155
+ .workspace-dashboard-surface .workspace-tabs button {
2156
+ min-height: 30px;
2157
+ }
2158
+ .workspace-dashboard-surface .workspace-grid {
2159
+ gap: 12px;
2160
+ padding: 18px;
2161
+ }
2162
+ .workspace-dashboard-surface .workspace-grid-cell {
2163
+ background: #ffffff;
2164
+ border-color: #e8e8e8;
2165
+ }
2166
+ .workspace-dashboard-surface.is-dashboard-editing {
2167
+ padding-inline: 16px;
2168
+ }
2169
+ .workspace-dashboard-surface.is-dashboard-editing .workspace-canvas {
2170
+ max-height: calc(100vh - 64px);
2171
+ }
2172
+ .workspace-dashboard-surface.is-dashboard-editing .workspace-grid {
2173
+ gap: 10px;
2174
+ padding: 14px;
2175
+ }
2176
+
2177
+ @media (max-width: 900px) {
2178
+ .workspace-dashboard-surface {
2179
+ padding-inline: 20px;
2180
+ }
2181
+ .workspace-dashboard-surface .dm-workflow-toolbar {
2182
+ margin-inline: 0;
2183
+ }
2184
+ }
2185
+
2131
2186
  @media (max-width: 1080px) {
2132
2187
  .workspace-builder {
2133
2188
  grid-template-columns: 180px minmax(0, 1fr);
@@ -4890,32 +4945,49 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
4890
4945
  background: #f7f7f8;
4891
4946
  overflow: hidden;
4892
4947
  }
4948
+ .workspace-surface.workspace-dashboard-surface {
4949
+ background: #f7f7f8;
4950
+ padding: 0 32px 24px;
4951
+ }
4952
+ .workspace-surface.workspace-dashboard-surface.is-dashboard-editing {
4953
+ padding-inline: 16px;
4954
+ }
4893
4955
  .dm-workflow-toolbar {
4894
4956
  height: 40px;
4895
4957
  min-height: 40px;
4896
4958
  align-items: center;
4897
4959
  flex-wrap: nowrap;
4898
4960
  gap: 12px;
4899
- padding: 0 12px;
4900
- margin: 0;
4961
+ padding: 0 32px;
4962
+ margin: 0 -32px 18px;
4901
4963
  border-bottom: 1px solid #ececef;
4902
4964
  background: #fbfbfc;
4903
4965
  overflow: hidden;
4904
4966
  }
4967
+ .workspace-surface.workspace-dashboard-surface .dm-workflow-toolbar {
4968
+ padding-inline: 32px;
4969
+ margin-inline: -32px;
4970
+ }
4971
+ .workspace-dashboard-surface.is-dashboard-editing .dm-workflow-toolbar {
4972
+ padding-inline: 16px;
4973
+ margin-inline: -16px;
4974
+ }
4905
4975
  .dm-workflow-titlebar {
4906
4976
  display: flex;
4907
4977
  align-items: center;
4908
4978
  gap: 6px;
4909
- min-width: 0;
4910
- flex: 1;
4979
+ min-width: max-content;
4980
+ flex: 1 0 auto;
4911
4981
  color: #8b8b91;
4912
4982
  font-size: 13px;
4913
4983
  font-weight: 500;
4984
+ white-space: nowrap;
4914
4985
  }
4915
4986
  .dm-workflow-titlebar h1 {
4916
4987
  margin: 0;
4917
- min-width: 0;
4918
- max-width: 260px;
4988
+ min-width: max-content;
4989
+ max-width: none;
4990
+ flex: 0 0 auto;
4919
4991
  overflow: hidden;
4920
4992
  text-overflow: ellipsis;
4921
4993
  white-space: nowrap;
@@ -4924,6 +4996,20 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
4924
4996
  font-weight: 600;
4925
4997
  letter-spacing: 0;
4926
4998
  }
4999
+ .dm-workflow-breadcrumb-link {
5000
+ appearance: none;
5001
+ border: 0;
5002
+ background: transparent;
5003
+ padding: 0;
5004
+ color: #a1a1aa;
5005
+ font: inherit;
5006
+ font-size: 13px;
5007
+ font-weight: 600;
5008
+ cursor: pointer;
5009
+ }
5010
+ .dm-workflow-breadcrumb-link:hover {
5011
+ color: #4b4b52;
5012
+ }
4927
5013
  .dm-workflow-title-icon {
4928
5014
  display: grid;
4929
5015
  place-items: center;
@@ -4943,8 +5029,9 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
4943
5029
  display: flex;
4944
5030
  align-items: center;
4945
5031
  gap: 6px;
4946
- flex-shrink: 0;
4947
- max-width: 66vw;
5032
+ flex: 0 1 auto;
5033
+ min-width: 0;
5034
+ max-width: 58vw;
4948
5035
  overflow-x: auto;
4949
5036
  scrollbar-width: none;
4950
5037
  }