@burdenoff/microfe-movethewheels 2026.1031.5 → 2026.1101.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/MoveTheWheelsRoot.js +141 -133
- package/dist/MoveTheWheelsRoutes.js +29 -25
- package/dist/hooks/useCarrierIntegrations.js +141 -0
- package/dist/movethewheels/hooks/useCarrierIntegrations.d.ts +39 -0
- package/dist/movethewheels/pages/FinancePage.d.ts +3 -1
- package/dist/movethewheels/pages/IntegrationsPage.d.ts +6 -0
- package/dist/movethewheels/utils/carrierIntegrations.d.ts +88 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/copy.js +16 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/file-check.js +18 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/pause.js +19 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/play.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/receipt.js +18 -0
- package/dist/pages/FinancePage.js +99 -71
- package/dist/pages/IntegrationsPage.js +616 -0
- package/dist/pages/SettingsPage.js +168 -150
- package/dist/utils/carrierIntegrations.js +105 -0
- package/package.json +3 -2
- package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
- package/src/movethewheels/components/UpcomingDemandPanel.tsx +1 -3
- package/src/movethewheels/hooks/useCarrierIntegrations.ts +221 -0
- package/src/movethewheels/pages/FinancePage.tsx +33 -3
- package/src/movethewheels/pages/IntegrationsPage.tsx +752 -0
- package/src/movethewheels/pages/SettingsPage.tsx +19 -3
- package/src/movethewheels/utils/carrierIntegrations.ts +252 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integrations Page (BOFF-4404)
|
|
3
|
+
*
|
|
4
|
+
* Connect external carrier, ELD and GPS feeds to MoveTheWheels. Each
|
|
5
|
+
* integration is a signed webhook: the sender posts tracking updates to the
|
|
6
|
+
* integration's URL, signed with HMAC-SHA256 over the raw body, and every
|
|
7
|
+
* accepted update becomes a tracking event on the matching shipment.
|
|
8
|
+
*
|
|
9
|
+
* The signing secret is generated in the browser, shown exactly once and never
|
|
10
|
+
* read back from the service. Named carrier networks (project44, FourKites,
|
|
11
|
+
* Descartes) are listed as not yet available: they need vendor agreements
|
|
12
|
+
* before an adapter can ship, and the service refuses them.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
|
15
|
+
import {
|
|
16
|
+
Activity,
|
|
17
|
+
Check,
|
|
18
|
+
ChevronDown,
|
|
19
|
+
ChevronUp,
|
|
20
|
+
Copy,
|
|
21
|
+
KeyRound,
|
|
22
|
+
Pause,
|
|
23
|
+
Play,
|
|
24
|
+
Plug,
|
|
25
|
+
Plus,
|
|
26
|
+
RefreshCw,
|
|
27
|
+
ShieldCheck,
|
|
28
|
+
Trash2,
|
|
29
|
+
} from 'lucide-react';
|
|
30
|
+
import {
|
|
31
|
+
CTAOverflowMenu,
|
|
32
|
+
PagePurpose,
|
|
33
|
+
ResponsiveDialog,
|
|
34
|
+
ResponsiveDialogContent,
|
|
35
|
+
ResponsiveDialogDescription,
|
|
36
|
+
ResponsiveDialogFooter,
|
|
37
|
+
ResponsiveDialogHeader,
|
|
38
|
+
ResponsiveDialogTitle,
|
|
39
|
+
} from '@burdenoff/fe-libs/ui';
|
|
40
|
+
import { PageLayout } from '../components/PageLayout';
|
|
41
|
+
import { MetricCard, MetricGrid } from '../components/MetricCard';
|
|
42
|
+
import { Notice, useNotice } from '../components/Notice';
|
|
43
|
+
import { FormError } from '../components/FormError';
|
|
44
|
+
import {
|
|
45
|
+
Badge,
|
|
46
|
+
Button,
|
|
47
|
+
Card,
|
|
48
|
+
CardContent,
|
|
49
|
+
Input,
|
|
50
|
+
Label,
|
|
51
|
+
Select,
|
|
52
|
+
SelectContent,
|
|
53
|
+
SelectItem,
|
|
54
|
+
SelectTrigger,
|
|
55
|
+
SelectValue,
|
|
56
|
+
Skeleton,
|
|
57
|
+
} from '../components/ui';
|
|
58
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
59
|
+
import {
|
|
60
|
+
useCarrierIntegrationActions,
|
|
61
|
+
useCarrierIntegrationCounts,
|
|
62
|
+
useCarrierIntegrationEvents,
|
|
63
|
+
useCarrierIntegrations,
|
|
64
|
+
} from '../hooks/useCarrierIntegrations';
|
|
65
|
+
import {
|
|
66
|
+
INTEGRATION_STATUSES,
|
|
67
|
+
MAX_UPDATES_PER_DELIVERY,
|
|
68
|
+
ROADMAP_PROVIDERS,
|
|
69
|
+
SIGNATURE_HEADER,
|
|
70
|
+
TRACKING_EVENT_TYPES,
|
|
71
|
+
curlExample,
|
|
72
|
+
eventStatusTone,
|
|
73
|
+
examplePayload,
|
|
74
|
+
generateSigningSecret,
|
|
75
|
+
humanize,
|
|
76
|
+
integrationNameError,
|
|
77
|
+
integrationStatusTone,
|
|
78
|
+
signingSecretError,
|
|
79
|
+
statusExplanation,
|
|
80
|
+
webhookUrl,
|
|
81
|
+
type CarrierIntegration,
|
|
82
|
+
type IntegrationStatus,
|
|
83
|
+
} from '../utils/carrierIntegrations';
|
|
84
|
+
import { formatDateTime } from '../utils/formatters';
|
|
85
|
+
|
|
86
|
+
const PAGE_SIZE = 20;
|
|
87
|
+
const ALL = 'ALL';
|
|
88
|
+
|
|
89
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
90
|
+
return error instanceof Error && error.message ? error.message : fallback;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function CopyField({
|
|
94
|
+
label,
|
|
95
|
+
value,
|
|
96
|
+
multiline,
|
|
97
|
+
}: {
|
|
98
|
+
label: string;
|
|
99
|
+
value: string;
|
|
100
|
+
multiline?: boolean;
|
|
101
|
+
}) {
|
|
102
|
+
const [copied, setCopied] = useState(false);
|
|
103
|
+
const copy = async () => {
|
|
104
|
+
try {
|
|
105
|
+
await navigator.clipboard.writeText(value);
|
|
106
|
+
setCopied(true);
|
|
107
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
108
|
+
} catch {
|
|
109
|
+
setCopied(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return (
|
|
113
|
+
<div className="space-y-1">
|
|
114
|
+
<div className="flex items-center justify-between gap-2">
|
|
115
|
+
<span className="text-xs font-medium text-text-secondary">{label}</span>
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
size="sm"
|
|
119
|
+
variant="ghost"
|
|
120
|
+
onClick={() => void copy()}
|
|
121
|
+
aria-label={`Copy ${label.toLowerCase()}`}
|
|
122
|
+
>
|
|
123
|
+
{copied ? <Check size={14} className="mr-1" /> : <Copy size={14} className="mr-1" />}
|
|
124
|
+
{copied ? 'Copied' : 'Copy'}
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
{multiline ? (
|
|
128
|
+
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-bg-sunken px-3 py-2 font-mono text-xs text-text-primary">
|
|
129
|
+
{value}
|
|
130
|
+
</pre>
|
|
131
|
+
) : (
|
|
132
|
+
<code className="block break-all rounded-lg bg-bg-sunken px-3 py-2 font-mono text-xs text-text-primary">
|
|
133
|
+
{value}
|
|
134
|
+
</code>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Shown once after a secret is generated; the service never returns it again. */
|
|
141
|
+
function SecretRevealDialog({
|
|
142
|
+
secret,
|
|
143
|
+
integrationName,
|
|
144
|
+
onClose,
|
|
145
|
+
}: {
|
|
146
|
+
secret: string | null;
|
|
147
|
+
integrationName: string;
|
|
148
|
+
onClose: () => void;
|
|
149
|
+
}) {
|
|
150
|
+
return (
|
|
151
|
+
<ResponsiveDialog open={secret !== null} onOpenChange={(open) => !open && onClose()}>
|
|
152
|
+
<ResponsiveDialogContent>
|
|
153
|
+
<ResponsiveDialogHeader>
|
|
154
|
+
<ResponsiveDialogTitle>Signing secret for {integrationName}</ResponsiveDialogTitle>
|
|
155
|
+
<ResponsiveDialogDescription>
|
|
156
|
+
Copy it into the sending system now. MoveTheWheels stores it to verify deliveries but
|
|
157
|
+
will not show it again; rotate it if it is lost.
|
|
158
|
+
</ResponsiveDialogDescription>
|
|
159
|
+
</ResponsiveDialogHeader>
|
|
160
|
+
{secret ? <CopyField key={secret} label="Signing secret" value={secret} /> : null}
|
|
161
|
+
<ResponsiveDialogFooter>
|
|
162
|
+
<Button onClick={onClose}>I have saved it</Button>
|
|
163
|
+
</ResponsiveDialogFooter>
|
|
164
|
+
</ResponsiveDialogContent>
|
|
165
|
+
</ResponsiveDialog>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function CreateIntegrationDialog({
|
|
170
|
+
open,
|
|
171
|
+
onOpenChange,
|
|
172
|
+
onCreated,
|
|
173
|
+
}: {
|
|
174
|
+
open: boolean;
|
|
175
|
+
onOpenChange: (open: boolean) => void;
|
|
176
|
+
onCreated: (integration: CarrierIntegration, secret: string) => void;
|
|
177
|
+
}) {
|
|
178
|
+
const actions = useCarrierIntegrationActions();
|
|
179
|
+
const [name, setName] = useState('');
|
|
180
|
+
const [secret, setSecret] = useState(() => generateSigningSecret());
|
|
181
|
+
const [error, setError] = useState<string | null>(null);
|
|
182
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
183
|
+
|
|
184
|
+
const reset = () => {
|
|
185
|
+
setName('');
|
|
186
|
+
setSecret(generateSigningSecret());
|
|
187
|
+
setError(null);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const submit = async (event: FormEvent) => {
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
const problem = integrationNameError(name) ?? signingSecretError(secret);
|
|
193
|
+
if (problem) {
|
|
194
|
+
setError(problem);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
setIsSaving(true);
|
|
198
|
+
setError(null);
|
|
199
|
+
try {
|
|
200
|
+
const created = await actions.create({
|
|
201
|
+
provider: 'GENERIC_WEBHOOK',
|
|
202
|
+
name,
|
|
203
|
+
apiSecret: secret,
|
|
204
|
+
});
|
|
205
|
+
onCreated(created, secret);
|
|
206
|
+
reset();
|
|
207
|
+
onOpenChange(false);
|
|
208
|
+
} catch (createError) {
|
|
209
|
+
setError(errorMessage(createError, 'Could not create the integration.'));
|
|
210
|
+
} finally {
|
|
211
|
+
setIsSaving(false);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<ResponsiveDialog
|
|
217
|
+
open={open}
|
|
218
|
+
onOpenChange={(next) => {
|
|
219
|
+
if (!next) reset();
|
|
220
|
+
onOpenChange(next);
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<ResponsiveDialogContent>
|
|
224
|
+
<form onSubmit={(event) => void submit(event)} className="space-y-4">
|
|
225
|
+
<ResponsiveDialogHeader>
|
|
226
|
+
<ResponsiveDialogTitle>Connect a tracking feed</ResponsiveDialogTitle>
|
|
227
|
+
<ResponsiveDialogDescription>
|
|
228
|
+
A signed webhook for a carrier, ELD or GPS provider. Updates it sends become tracking
|
|
229
|
+
events on the matching shipments.
|
|
230
|
+
</ResponsiveDialogDescription>
|
|
231
|
+
</ResponsiveDialogHeader>
|
|
232
|
+
<div className="space-y-1.5">
|
|
233
|
+
<Label htmlFor="integration-name">Name</Label>
|
|
234
|
+
<Input
|
|
235
|
+
id="integration-name"
|
|
236
|
+
value={name}
|
|
237
|
+
maxLength={120}
|
|
238
|
+
placeholder="e.g. Fleet GPS feed"
|
|
239
|
+
onChange={(event) => setName(event.target.value)}
|
|
240
|
+
autoFocus
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="space-y-1.5">
|
|
244
|
+
<Label>Type</Label>
|
|
245
|
+
<p className="rounded-lg border border-border-default px-3 py-2 text-sm">
|
|
246
|
+
Signed webhook (any carrier, ELD or GPS system that can send HTTPS POSTs)
|
|
247
|
+
</p>
|
|
248
|
+
</div>
|
|
249
|
+
<div className="space-y-1.5">
|
|
250
|
+
<div className="flex items-center justify-between">
|
|
251
|
+
<Label>Signing secret</Label>
|
|
252
|
+
<Button
|
|
253
|
+
type="button"
|
|
254
|
+
size="sm"
|
|
255
|
+
variant="ghost"
|
|
256
|
+
onClick={() => setSecret(generateSigningSecret())}
|
|
257
|
+
>
|
|
258
|
+
<RefreshCw size={14} className="mr-1" />
|
|
259
|
+
Generate another
|
|
260
|
+
</Button>
|
|
261
|
+
</div>
|
|
262
|
+
<code className="block break-all rounded-lg bg-bg-sunken px-3 py-2 font-mono text-xs">
|
|
263
|
+
{secret}
|
|
264
|
+
</code>
|
|
265
|
+
<p className="text-xs text-text-secondary">
|
|
266
|
+
Generated in your browser. You can copy it on the next step.
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
<FormError message={error} />
|
|
270
|
+
<ResponsiveDialogFooter>
|
|
271
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
272
|
+
Cancel
|
|
273
|
+
</Button>
|
|
274
|
+
<Button type="submit" disabled={isSaving}>
|
|
275
|
+
{isSaving ? 'Connecting…' : 'Connect'}
|
|
276
|
+
</Button>
|
|
277
|
+
</ResponsiveDialogFooter>
|
|
278
|
+
</form>
|
|
279
|
+
</ResponsiveDialogContent>
|
|
280
|
+
</ResponsiveDialog>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function DeliveryLog({ integrationId, refreshKey }: { integrationId: string; refreshKey: number }) {
|
|
285
|
+
const { events, isLoading, error } = useCarrierIntegrationEvents(integrationId, refreshKey);
|
|
286
|
+
return (
|
|
287
|
+
<div className="space-y-2">
|
|
288
|
+
<p className="text-sm font-medium">Recent deliveries</p>
|
|
289
|
+
<FormError message={error?.message} />
|
|
290
|
+
{isLoading && events.length === 0 ? (
|
|
291
|
+
<Skeleton className="h-12 w-full" />
|
|
292
|
+
) : events.length === 0 ? (
|
|
293
|
+
<p className="text-sm text-text-secondary">
|
|
294
|
+
No deliveries yet. Send a signed test update with the example below.
|
|
295
|
+
</p>
|
|
296
|
+
) : (
|
|
297
|
+
<ul className="space-y-2">
|
|
298
|
+
{events.map((event) => (
|
|
299
|
+
<li
|
|
300
|
+
key={event.id}
|
|
301
|
+
className="flex flex-wrap items-start justify-between gap-3 rounded-lg bg-bg-sunken px-3 py-2"
|
|
302
|
+
>
|
|
303
|
+
<div className="min-w-0">
|
|
304
|
+
<p className="text-sm font-medium">
|
|
305
|
+
{event.eventType ? humanize(event.eventType) : 'Delivery'}
|
|
306
|
+
{event.externalEventId ? (
|
|
307
|
+
<span className="ml-2 font-mono text-xs text-text-tertiary">
|
|
308
|
+
{event.externalEventId}
|
|
309
|
+
</span>
|
|
310
|
+
) : null}
|
|
311
|
+
</p>
|
|
312
|
+
<p className="text-xs text-text-secondary">{formatDateTime(event.receivedAt)}</p>
|
|
313
|
+
{event.errorMessage ? (
|
|
314
|
+
<p className="mt-1 break-words text-xs text-status-error-text">
|
|
315
|
+
{event.errorMessage}
|
|
316
|
+
</p>
|
|
317
|
+
) : null}
|
|
318
|
+
</div>
|
|
319
|
+
<Badge variant={eventStatusTone(event.status)}>{humanize(event.status)}</Badge>
|
|
320
|
+
</li>
|
|
321
|
+
))}
|
|
322
|
+
</ul>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function SetupInstructions({ url }: { url: string }) {
|
|
329
|
+
return (
|
|
330
|
+
<div className="space-y-3">
|
|
331
|
+
<p className="text-sm font-medium">How to send updates</p>
|
|
332
|
+
<CopyField label="Webhook URL" value={url} />
|
|
333
|
+
<ul className="list-disc space-y-1 pl-5 text-xs text-text-secondary">
|
|
334
|
+
<li>POST JSON: one update object, or an array of up to {MAX_UPDATES_PER_DELIVERY}.</li>
|
|
335
|
+
<li>
|
|
336
|
+
Sign the exact raw body with HMAC-SHA256 using the signing secret and send the hex digest
|
|
337
|
+
in the <code className="font-mono">{SIGNATURE_HEADER}</code> header (a{' '}
|
|
338
|
+
<code className="font-mono">sha256=</code> prefix is accepted).
|
|
339
|
+
</li>
|
|
340
|
+
<li>
|
|
341
|
+
Identify the shipment with <code className="font-mono">trackingNumber</code> or its{' '}
|
|
342
|
+
<code className="font-mono">shipmentId</code>. Event types:{' '}
|
|
343
|
+
{TRACKING_EVENT_TYPES.join(', ')}.
|
|
344
|
+
</li>
|
|
345
|
+
<li>
|
|
346
|
+
Send a stable <code className="font-mono">externalEventId</code> so retries are recorded
|
|
347
|
+
once instead of twice.
|
|
348
|
+
</li>
|
|
349
|
+
</ul>
|
|
350
|
+
<CopyField label="Example body" value={examplePayload()} multiline />
|
|
351
|
+
<CopyField label="Example request" value={curlExample(url)} multiline />
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function IntegrationCard({
|
|
357
|
+
integration,
|
|
358
|
+
url,
|
|
359
|
+
expanded,
|
|
360
|
+
refreshKey,
|
|
361
|
+
busy,
|
|
362
|
+
onToggle,
|
|
363
|
+
onSetStatus,
|
|
364
|
+
onRotate,
|
|
365
|
+
onDelete,
|
|
366
|
+
}: {
|
|
367
|
+
integration: CarrierIntegration;
|
|
368
|
+
url: string;
|
|
369
|
+
expanded: boolean;
|
|
370
|
+
refreshKey: number;
|
|
371
|
+
/** An action on this integration is in flight; its other actions wait. */
|
|
372
|
+
busy: boolean;
|
|
373
|
+
onToggle: () => void;
|
|
374
|
+
onSetStatus: (status: IntegrationStatus) => void;
|
|
375
|
+
onRotate: () => void;
|
|
376
|
+
onDelete: () => void;
|
|
377
|
+
}) {
|
|
378
|
+
const isActive = integration.status === 'ACTIVE';
|
|
379
|
+
return (
|
|
380
|
+
<Card>
|
|
381
|
+
<CardContent className="p-5">
|
|
382
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
383
|
+
<div className="min-w-0 space-y-1">
|
|
384
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
385
|
+
<p className="text-base font-semibold">{integration.name}</p>
|
|
386
|
+
<Badge variant={integrationStatusTone(integration.status)}>
|
|
387
|
+
{humanize(integration.status)}
|
|
388
|
+
</Badge>
|
|
389
|
+
</div>
|
|
390
|
+
<p className="text-xs text-text-secondary">
|
|
391
|
+
Signed webhook · {statusExplanation(integration.status)}
|
|
392
|
+
</p>
|
|
393
|
+
<p className="text-xs text-text-secondary">
|
|
394
|
+
Last accepted update:{' '}
|
|
395
|
+
{integration.lastSyncAt ? formatDateTime(integration.lastSyncAt) : 'none yet'}
|
|
396
|
+
{integration.lastErrorAt
|
|
397
|
+
? ` · Last error ${formatDateTime(integration.lastErrorAt)}`
|
|
398
|
+
: ''}
|
|
399
|
+
</p>
|
|
400
|
+
{integration.lastErrorMessage ? (
|
|
401
|
+
<p className="break-words text-xs text-status-error-text">
|
|
402
|
+
{integration.lastErrorMessage}
|
|
403
|
+
</p>
|
|
404
|
+
) : null}
|
|
405
|
+
</div>
|
|
406
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
407
|
+
<Button
|
|
408
|
+
size="sm"
|
|
409
|
+
variant="outline"
|
|
410
|
+
disabled={busy}
|
|
411
|
+
onClick={() => onSetStatus(isActive ? 'PAUSED' : 'ACTIVE')}
|
|
412
|
+
>
|
|
413
|
+
{isActive ? (
|
|
414
|
+
<Pause size={14} className="mr-1" />
|
|
415
|
+
) : (
|
|
416
|
+
<Play size={14} className="mr-1" />
|
|
417
|
+
)}
|
|
418
|
+
{isActive ? 'Pause' : 'Resume'}
|
|
419
|
+
</Button>
|
|
420
|
+
<Button size="sm" variant="outline" disabled={busy} onClick={onRotate}>
|
|
421
|
+
<KeyRound size={14} className="mr-1" />
|
|
422
|
+
Rotate secret
|
|
423
|
+
</Button>
|
|
424
|
+
<Button
|
|
425
|
+
size="sm"
|
|
426
|
+
variant="ghost"
|
|
427
|
+
disabled={busy}
|
|
428
|
+
onClick={onDelete}
|
|
429
|
+
aria-label={`Delete ${integration.name}`}
|
|
430
|
+
>
|
|
431
|
+
<Trash2 size={14} />
|
|
432
|
+
</Button>
|
|
433
|
+
<Button
|
|
434
|
+
size="sm"
|
|
435
|
+
variant="ghost"
|
|
436
|
+
onClick={onToggle}
|
|
437
|
+
aria-expanded={expanded}
|
|
438
|
+
aria-label={expanded ? 'Hide details' : 'Show details'}
|
|
439
|
+
>
|
|
440
|
+
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
441
|
+
</Button>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
{expanded ? (
|
|
445
|
+
<div className="mt-4 grid gap-6 border-t border-border-subtle pt-4 lg:grid-cols-2">
|
|
446
|
+
<SetupInstructions url={url} />
|
|
447
|
+
<DeliveryLog integrationId={integration.id} refreshKey={refreshKey} />
|
|
448
|
+
</div>
|
|
449
|
+
) : null}
|
|
450
|
+
</CardContent>
|
|
451
|
+
</Card>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Remounts the page whenever the host switches tenant or workspace, so no
|
|
457
|
+
* server state, filter, page, pending reveal or in-flight mutation from the
|
|
458
|
+
* previous workspace survives into the next one.
|
|
459
|
+
*/
|
|
460
|
+
export default function IntegrationsPage() {
|
|
461
|
+
const { tenantId, workspaceId } = useMoveTheWheels();
|
|
462
|
+
return <WorkspaceIntegrationsPage key={`${tenantId ?? ''}:${workspaceId ?? ''}`} />;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function WorkspaceIntegrationsPage() {
|
|
466
|
+
const { apiGatewayUrl } = useMoveTheWheels();
|
|
467
|
+
const actions = useCarrierIntegrationActions();
|
|
468
|
+
const { message: notice, notify } = useNotice();
|
|
469
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
470
|
+
const [status, setStatus] = useState<IntegrationStatus | typeof ALL>(ALL);
|
|
471
|
+
const [page, setPage] = useState(0);
|
|
472
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
473
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
474
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
475
|
+
// Secrets waiting to be shown, oldest first. Each is shown once and cannot be
|
|
476
|
+
// read back, so concurrent rotations on different feeds queue instead of
|
|
477
|
+
// replacing an unacknowledged secret.
|
|
478
|
+
const [reveals, setReveals] = useState<{ name: string; secret: string }[]>([]);
|
|
479
|
+
const revealed = reveals[0] ?? null;
|
|
480
|
+
const reveal = (name: string, secret: string) =>
|
|
481
|
+
setReveals((queue) => [...queue, { name, secret }]);
|
|
482
|
+
// One write per integration at a time: two overlapping secret rotations
|
|
483
|
+
// could otherwise reveal a secret the server has already replaced.
|
|
484
|
+
const busyRef = useRef(new Set<string>());
|
|
485
|
+
const [busyIds, setBusyIds] = useState<ReadonlySet<string>>(() => new Set());
|
|
486
|
+
const exclusive = async (id: string, action: () => Promise<void>) => {
|
|
487
|
+
if (busyRef.current.has(id)) return;
|
|
488
|
+
busyRef.current.add(id);
|
|
489
|
+
setBusyIds(new Set(busyRef.current));
|
|
490
|
+
try {
|
|
491
|
+
await action();
|
|
492
|
+
} finally {
|
|
493
|
+
busyRef.current.delete(id);
|
|
494
|
+
setBusyIds(new Set(busyRef.current));
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const { integrations, total, isLoading, error, refetch } = useCarrierIntegrations({
|
|
499
|
+
status: status === ALL ? undefined : status,
|
|
500
|
+
limit: PAGE_SIZE,
|
|
501
|
+
offset: page * PAGE_SIZE,
|
|
502
|
+
});
|
|
503
|
+
// Workspace-wide totals, independent of the status filter and page.
|
|
504
|
+
const {
|
|
505
|
+
all: allFeeds,
|
|
506
|
+
active: activeFeeds,
|
|
507
|
+
refetch: refetchCounts,
|
|
508
|
+
} = useCarrierIntegrationCounts();
|
|
509
|
+
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
510
|
+
// A delete or pause can empty the last page; step back to the last page
|
|
511
|
+
// that still has feeds instead of showing a false empty state.
|
|
512
|
+
useEffect(() => {
|
|
513
|
+
if (!isLoading && page > 0 && page >= pageCount) setPage(pageCount - 1);
|
|
514
|
+
}, [isLoading, page, pageCount]);
|
|
515
|
+
const syncTimes = integrations
|
|
516
|
+
.map((integration) => integration.lastSyncAt)
|
|
517
|
+
.filter((value): value is string => Boolean(value))
|
|
518
|
+
.sort();
|
|
519
|
+
const lastSync = syncTimes.length > 0 ? syncTimes[syncTimes.length - 1] : undefined;
|
|
520
|
+
|
|
521
|
+
const urlFor = (integration: CarrierIntegration) =>
|
|
522
|
+
// The row's own workspace, never the host's current one: the URL must
|
|
523
|
+
// address the workspace that owns the integration.
|
|
524
|
+
webhookUrl(apiGatewayUrl, integration.workspaceId, integration.id, window.location.origin);
|
|
525
|
+
|
|
526
|
+
const refresh = () => {
|
|
527
|
+
setRefreshKey((key) => key + 1);
|
|
528
|
+
void refetch();
|
|
529
|
+
void refetchCounts();
|
|
530
|
+
};
|
|
531
|
+
const done = (message: string) => {
|
|
532
|
+
setActionError(null);
|
|
533
|
+
notify(message);
|
|
534
|
+
refresh();
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const setIntegrationStatus = async (integration: CarrierIntegration, next: IntegrationStatus) => {
|
|
538
|
+
try {
|
|
539
|
+
await actions.update(integration.id, { status: next });
|
|
540
|
+
done(next === 'ACTIVE' ? `Resumed ${integration.name}.` : `Paused ${integration.name}.`);
|
|
541
|
+
} catch (updateError) {
|
|
542
|
+
setActionError(errorMessage(updateError, 'Could not change the integration status.'));
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const rotateSecret = async (integration: CarrierIntegration) => {
|
|
547
|
+
if (
|
|
548
|
+
!window.confirm(
|
|
549
|
+
`Rotate the signing secret for ${integration.name}? Deliveries signed with the old secret are refused as soon as it changes.`
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
return;
|
|
553
|
+
const secret = generateSigningSecret();
|
|
554
|
+
try {
|
|
555
|
+
await actions.update(integration.id, { apiSecret: secret });
|
|
556
|
+
reveal(integration.name, secret);
|
|
557
|
+
done(`Rotated the signing secret for ${integration.name}.`);
|
|
558
|
+
} catch (rotateError) {
|
|
559
|
+
setActionError(errorMessage(rotateError, 'Could not rotate the signing secret.'));
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const removeIntegration = async (integration: CarrierIntegration) => {
|
|
564
|
+
if (
|
|
565
|
+
!window.confirm(
|
|
566
|
+
`Delete ${integration.name}? Its webhook URL stops working; tracking events it already created stay on their shipments.`
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
return;
|
|
570
|
+
try {
|
|
571
|
+
await actions.remove(integration.id);
|
|
572
|
+
if (expandedId === integration.id) setExpandedId(null);
|
|
573
|
+
done(`Deleted ${integration.name}.`);
|
|
574
|
+
} catch (removeError) {
|
|
575
|
+
setActionError(errorMessage(removeError, 'Could not delete the integration.'));
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
return (
|
|
580
|
+
<PageLayout
|
|
581
|
+
title="Integrations"
|
|
582
|
+
subtitle="Carrier, ELD and GPS tracking feeds"
|
|
583
|
+
icon={<Plug size={24} />}
|
|
584
|
+
headerActions={
|
|
585
|
+
<CTAOverflowMenu
|
|
586
|
+
primary={{
|
|
587
|
+
label: 'Connect feed',
|
|
588
|
+
icon: <Plus size={16} />,
|
|
589
|
+
onSelect: () => setCreateOpen(true),
|
|
590
|
+
}}
|
|
591
|
+
actions={[{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: refresh }]}
|
|
592
|
+
/>
|
|
593
|
+
}
|
|
594
|
+
>
|
|
595
|
+
<PagePurpose icon={<Plug size={18} />}>
|
|
596
|
+
Connect the systems that already know where your freight is. Each feed gets its own signed
|
|
597
|
+
webhook; every update it sends becomes a tracking event on the matching shipment, so status
|
|
598
|
+
and ETA stay current without manual entry.
|
|
599
|
+
</PagePurpose>
|
|
600
|
+
|
|
601
|
+
<Notice message={notice} />
|
|
602
|
+
<FormError message={actionError} />
|
|
603
|
+
|
|
604
|
+
<MetricGrid columns={3}>
|
|
605
|
+
<MetricCard label="Feeds" value={allFeeds} icon={<Plug size={20} />} accent="brand" />
|
|
606
|
+
<MetricCard
|
|
607
|
+
label="Active"
|
|
608
|
+
value={activeFeeds}
|
|
609
|
+
icon={<ShieldCheck size={20} />}
|
|
610
|
+
accent="success"
|
|
611
|
+
/>
|
|
612
|
+
<MetricCard
|
|
613
|
+
label="Last accepted update (shown)"
|
|
614
|
+
value={lastSync ? formatDateTime(lastSync) : '—'}
|
|
615
|
+
icon={<Activity size={20} />}
|
|
616
|
+
accent="blue"
|
|
617
|
+
/>
|
|
618
|
+
</MetricGrid>
|
|
619
|
+
|
|
620
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
621
|
+
<Select
|
|
622
|
+
value={status}
|
|
623
|
+
onValueChange={(value) => {
|
|
624
|
+
setStatus(value as IntegrationStatus | typeof ALL);
|
|
625
|
+
setPage(0);
|
|
626
|
+
}}
|
|
627
|
+
>
|
|
628
|
+
<SelectTrigger aria-label="Status" className="sm:w-56">
|
|
629
|
+
<SelectValue />
|
|
630
|
+
</SelectTrigger>
|
|
631
|
+
<SelectContent>
|
|
632
|
+
<SelectItem value={ALL}>All statuses</SelectItem>
|
|
633
|
+
{INTEGRATION_STATUSES.map((option) => (
|
|
634
|
+
<SelectItem key={option} value={option}>
|
|
635
|
+
{humanize(option)}
|
|
636
|
+
</SelectItem>
|
|
637
|
+
))}
|
|
638
|
+
</SelectContent>
|
|
639
|
+
</Select>
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
<FormError message={error?.message} />
|
|
643
|
+
|
|
644
|
+
{isLoading && integrations.length === 0 ? (
|
|
645
|
+
<div className="grid gap-4">
|
|
646
|
+
<Skeleton className="h-28 w-full" />
|
|
647
|
+
<Skeleton className="h-28 w-full" />
|
|
648
|
+
</div>
|
|
649
|
+
) : integrations.length === 0 && !error ? (
|
|
650
|
+
<Card>
|
|
651
|
+
<CardContent className="flex flex-col items-center gap-3 p-10 text-center">
|
|
652
|
+
<Plug size={32} className="text-text-tertiary" />
|
|
653
|
+
<p className="font-medium">
|
|
654
|
+
{status === ALL ? 'No tracking feeds connected' : 'No feeds with this status'}
|
|
655
|
+
</p>
|
|
656
|
+
<p className="max-w-md text-sm text-text-secondary">
|
|
657
|
+
{status === ALL
|
|
658
|
+
? 'Connect a carrier portal, ELD or GPS tracker so shipment updates arrive on their own.'
|
|
659
|
+
: 'Choose another status to see the rest of your feeds.'}
|
|
660
|
+
</p>
|
|
661
|
+
{status === ALL ? (
|
|
662
|
+
<Button onClick={() => setCreateOpen(true)}>
|
|
663
|
+
<Plus size={16} className="mr-1.5" />
|
|
664
|
+
Connect feed
|
|
665
|
+
</Button>
|
|
666
|
+
) : null}
|
|
667
|
+
</CardContent>
|
|
668
|
+
</Card>
|
|
669
|
+
) : (
|
|
670
|
+
<div className="grid gap-4">
|
|
671
|
+
{integrations.map((integration) => (
|
|
672
|
+
<IntegrationCard
|
|
673
|
+
key={integration.id}
|
|
674
|
+
integration={integration}
|
|
675
|
+
url={urlFor(integration)}
|
|
676
|
+
expanded={expandedId === integration.id}
|
|
677
|
+
refreshKey={refreshKey}
|
|
678
|
+
busy={busyIds.has(integration.id)}
|
|
679
|
+
onToggle={() =>
|
|
680
|
+
setExpandedId((id) => (id === integration.id ? null : integration.id))
|
|
681
|
+
}
|
|
682
|
+
onSetStatus={(next) =>
|
|
683
|
+
void exclusive(integration.id, () => setIntegrationStatus(integration, next))
|
|
684
|
+
}
|
|
685
|
+
onRotate={() => void exclusive(integration.id, () => rotateSecret(integration))}
|
|
686
|
+
onDelete={() => void exclusive(integration.id, () => removeIntegration(integration))}
|
|
687
|
+
/>
|
|
688
|
+
))}
|
|
689
|
+
</div>
|
|
690
|
+
)}
|
|
691
|
+
|
|
692
|
+
{total > PAGE_SIZE ? (
|
|
693
|
+
<div className="flex items-center justify-between text-sm">
|
|
694
|
+
<span className="text-text-secondary">
|
|
695
|
+
Page {page + 1} of {pageCount} · {total} feeds
|
|
696
|
+
</span>
|
|
697
|
+
<div className="flex gap-2">
|
|
698
|
+
<Button
|
|
699
|
+
variant="outline"
|
|
700
|
+
size="sm"
|
|
701
|
+
disabled={page === 0}
|
|
702
|
+
onClick={() => setPage((p) => p - 1)}
|
|
703
|
+
>
|
|
704
|
+
Previous
|
|
705
|
+
</Button>
|
|
706
|
+
<Button
|
|
707
|
+
variant="outline"
|
|
708
|
+
size="sm"
|
|
709
|
+
disabled={page + 1 >= pageCount}
|
|
710
|
+
onClick={() => setPage((p) => p + 1)}
|
|
711
|
+
>
|
|
712
|
+
Next
|
|
713
|
+
</Button>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
) : null}
|
|
717
|
+
|
|
718
|
+
<Card>
|
|
719
|
+
<CardContent className="space-y-2 p-5">
|
|
720
|
+
<p className="text-sm font-medium">Carrier networks</p>
|
|
721
|
+
<p className="text-sm text-text-secondary">
|
|
722
|
+
Direct connections to {ROADMAP_PROVIDERS.map((provider) => provider.label).join(', ')}{' '}
|
|
723
|
+
are not available yet. Until they are, any carrier or telematics system that can send
|
|
724
|
+
HTTPS webhooks connects through a signed feed above.
|
|
725
|
+
</p>
|
|
726
|
+
<div className="flex flex-wrap gap-2">
|
|
727
|
+
{ROADMAP_PROVIDERS.map((provider) => (
|
|
728
|
+
<Badge key={provider.id} variant="outline">
|
|
729
|
+
{provider.label} · not available yet
|
|
730
|
+
</Badge>
|
|
731
|
+
))}
|
|
732
|
+
</div>
|
|
733
|
+
</CardContent>
|
|
734
|
+
</Card>
|
|
735
|
+
|
|
736
|
+
<CreateIntegrationDialog
|
|
737
|
+
open={createOpen}
|
|
738
|
+
onOpenChange={setCreateOpen}
|
|
739
|
+
onCreated={(integration, secret) => {
|
|
740
|
+
setExpandedId(integration.id);
|
|
741
|
+
reveal(integration.name, secret);
|
|
742
|
+
done(`Connected ${integration.name}.`);
|
|
743
|
+
}}
|
|
744
|
+
/>
|
|
745
|
+
<SecretRevealDialog
|
|
746
|
+
secret={revealed?.secret ?? null}
|
|
747
|
+
integrationName={revealed?.name ?? ''}
|
|
748
|
+
onClose={() => setReveals((queue) => queue.slice(1))}
|
|
749
|
+
/>
|
|
750
|
+
</PageLayout>
|
|
751
|
+
);
|
|
752
|
+
}
|