@startsimpli/integrations 0.1.6 → 0.2.2
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/package.json +2 -2
- package/src/__tests__/connections.test.tsx +176 -0
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/index.ts +1 -0
- package/src/components/pages/ConnectionsPage.tsx +229 -11
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/components/pages/types.ts +25 -0
- package/src/corpus/client.ts +85 -4
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +134 -0
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +176 -0
- package/src/corpus/index.ts +46 -0
- package/src/corpus/llm.ts +107 -1
- package/src/corpus/text.ts +283 -0
- package/src/corpus/types.ts +256 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TRACKING SURFACE, rendered (bd startsim-768w.38.25).
|
|
3
|
+
*
|
|
4
|
+
* The screen makes one claim — "this is what is still owed" — and every case
|
|
5
|
+
* below is a way that claim could be false on screen while the data underneath
|
|
6
|
+
* is fine: a closed item shown as outstanding, an item with no owner rendered
|
|
7
|
+
* as though it had one, a filter that did nothing, a row with no traceable
|
|
8
|
+
* source, a run that closed something and did not say why.
|
|
9
|
+
*/
|
|
10
|
+
import { render, screen, within } from '@testing-library/react';
|
|
11
|
+
import userEvent from '@testing-library/user-event';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { TrackerPage } from '../components/pages/TrackerPage';
|
|
15
|
+
import type {
|
|
16
|
+
Commitment,
|
|
17
|
+
CommitmentQuery,
|
|
18
|
+
CommitmentTrackerApi,
|
|
19
|
+
CorpusPage,
|
|
20
|
+
} from '../corpus/types';
|
|
21
|
+
|
|
22
|
+
const NOW = Date.parse('2026-08-10T12:00:00Z');
|
|
23
|
+
const SCOPE = { orgId: 'tandem' };
|
|
24
|
+
|
|
25
|
+
function commitment(overrides: Partial<Commitment> & { externalId: string; title: string }): Commitment {
|
|
26
|
+
return {
|
|
27
|
+
id: overrides.externalId,
|
|
28
|
+
dedupeKey: overrides.externalId.replace('commit:', ''),
|
|
29
|
+
subtype: 'action',
|
|
30
|
+
detail: '',
|
|
31
|
+
status: 'open',
|
|
32
|
+
subjectKey: '',
|
|
33
|
+
actionKey: '',
|
|
34
|
+
mentions: [
|
|
35
|
+
{
|
|
36
|
+
documentId: 'doc_1',
|
|
37
|
+
originKey: 'rules:x',
|
|
38
|
+
occurredAt: '2026-08-01T15:00:00Z',
|
|
39
|
+
quote: 'Status in the system reads "comments received", which is misleading.',
|
|
40
|
+
extractor: 'rules',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
firstSeenAt: '2026-08-01T15:00:00Z',
|
|
44
|
+
lastSeenAt: '2026-08-01T15:00:00Z',
|
|
45
|
+
extractorVersion: 'rules+model@1',
|
|
46
|
+
source: 'granola',
|
|
47
|
+
sourceLabel: 'Granola',
|
|
48
|
+
scope: SCOPE,
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ROWS: Commitment[] = [
|
|
54
|
+
commitment({ externalId: 'commit:open1', title: 'Chase the inventor for comments on 2148', ownerName: 'Rae' }),
|
|
55
|
+
commitment({
|
|
56
|
+
externalId: 'commit:overdue1',
|
|
57
|
+
title: 'Send the equity sheet to Harry Holcomb',
|
|
58
|
+
dueAt: '2026-08-05T00:00:00Z',
|
|
59
|
+
}),
|
|
60
|
+
commitment({
|
|
61
|
+
externalId: 'commit:done1',
|
|
62
|
+
title: 'File the PCT for 2205',
|
|
63
|
+
status: 'done',
|
|
64
|
+
statusReason: '2205: filed as first PCT on June 25; already complete',
|
|
65
|
+
resolvedBy: 'doc_9',
|
|
66
|
+
resolvedAt: '2026-08-08T00:00:00Z',
|
|
67
|
+
lastSeenAt: '2026-08-08T00:00:00Z',
|
|
68
|
+
}),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function makeApi(options: {
|
|
72
|
+
rows?: Commitment[];
|
|
73
|
+
appliedFilters?: string[];
|
|
74
|
+
onQuery?: (query: CommitmentQuery) => void;
|
|
75
|
+
extract?: CommitmentTrackerApi['extract'];
|
|
76
|
+
} = {}): CommitmentTrackerApi {
|
|
77
|
+
const rows = options.rows ?? ROWS;
|
|
78
|
+
return {
|
|
79
|
+
async listCommitments(query: CommitmentQuery = {}): Promise<CorpusPage<Commitment>> {
|
|
80
|
+
options.onQuery?.(query);
|
|
81
|
+
// Deliberately returns EVERYTHING, like the tenant API does today: the
|
|
82
|
+
// screen has to be correct against a server that ignores the filters.
|
|
83
|
+
return {
|
|
84
|
+
count: rows.length,
|
|
85
|
+
next: null,
|
|
86
|
+
previous: null,
|
|
87
|
+
results: rows,
|
|
88
|
+
appliedFilters: options.appliedFilters ?? [],
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
...(options.extract ? { extract: options.extract } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('TrackerPage — what is outstanding', () => {
|
|
96
|
+
it('shows open items and hides closed ones', async () => {
|
|
97
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
98
|
+
|
|
99
|
+
expect(await screen.findByText('Chase the inventor for comments on 2148')).toBeInTheDocument();
|
|
100
|
+
expect(screen.getByText('Send the equity sheet to Harry Holcomb')).toBeInTheDocument();
|
|
101
|
+
// The one thing this screen must never do: present finished work as owed.
|
|
102
|
+
expect(screen.queryByText('File the PCT for 2205')).not.toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('sends the status and window as SERVER parameters', async () => {
|
|
106
|
+
const queries: CommitmentQuery[] = [];
|
|
107
|
+
render(<TrackerPage api={makeApi({ onQuery: (query) => queries.push(query) })} scope={SCOPE} now={NOW} />);
|
|
108
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
109
|
+
|
|
110
|
+
expect(queries[0].status).toEqual(['open', 'blocked']);
|
|
111
|
+
expect(queries[0].pageSize).toBe(200);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('says so when the data API does not honour the filters', async () => {
|
|
115
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
116
|
+
// The Library's `appliedFilters` contract, owed here about status — a
|
|
117
|
+
// status filter that silently does nothing breaks the screen's only claim.
|
|
118
|
+
expect(
|
|
119
|
+
await screen.findByText(/does not filter by .*status/i),
|
|
120
|
+
).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does NOT nag when the API reports it applied them', async () => {
|
|
124
|
+
render(
|
|
125
|
+
<TrackerPage
|
|
126
|
+
api={makeApi({ appliedFilters: ['status', 'owner', 'dueBefore', 'changedAfter'] })}
|
|
127
|
+
scope={SCOPE}
|
|
128
|
+
now={NOW}
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
132
|
+
expect(screen.queryByText(/does not filter by/i)).not.toBeInTheDocument();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('puts an overdue item above one with no due date', async () => {
|
|
136
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
137
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
138
|
+
const titles = screen.getAllByRole('article').map((node) => within(node).getByRole('heading').textContent);
|
|
139
|
+
expect(titles[0]).toBe('Send the equity sheet to Harry Holcomb');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('says an item is overdue, with the date', async () => {
|
|
143
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
144
|
+
expect(await screen.findByText(/Overdue/)).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('says how long something has been open, which is the point of the screen', async () => {
|
|
148
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
149
|
+
// Raised 2026-08-01, read 2026-08-10 — nine calendar days, eight elapsed.
|
|
150
|
+
expect(await screen.findAllByText(/open for 8 days/)).not.toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('states that nobody was named rather than rendering a blank', async () => {
|
|
154
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
155
|
+
// "Nobody said who owns this" is a fact about the meeting, and it is the
|
|
156
|
+
// most actionable thing on the screen. A blank cell hides it.
|
|
157
|
+
expect(await screen.findByText('No owner named')).toBeInTheDocument();
|
|
158
|
+
expect(screen.getByText('Rae')).toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('renders every row with the meeting it came from and a verbatim quote', async () => {
|
|
162
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
163
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
164
|
+
for (const article of screen.getAllByRole('article')) {
|
|
165
|
+
expect(within(article).getByText(/From 1 meeting/)).toBeInTheDocument();
|
|
166
|
+
expect(within(article).getByText(/Status in the system reads/)).toBeInTheDocument();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('shows the closed tab with the sentence that closed it', async () => {
|
|
171
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
172
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
173
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Closed' }));
|
|
174
|
+
|
|
175
|
+
expect(await screen.findByText('File the PCT for 2205')).toBeInTheDocument();
|
|
176
|
+
// "Why does it say this is done" has to be answerable without opening the
|
|
177
|
+
// meeting, or nobody trusts a single closed row.
|
|
178
|
+
expect(screen.getByText(/Closed by: .*filed as first PCT/)).toBeInTheDocument();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('filters by owner', async () => {
|
|
182
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
183
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
184
|
+
await userEvent.type(screen.getByPlaceholderText('Owner'), 'Rae');
|
|
185
|
+
|
|
186
|
+
expect(screen.getByText('Chase the inventor for comments on 2148')).toBeInTheDocument();
|
|
187
|
+
expect(screen.queryByText('Send the equity sheet to Harry Holcomb')).not.toBeInTheDocument();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('tells a fresh workspace what to do instead of showing an empty page', async () => {
|
|
191
|
+
render(<TrackerPage api={makeApi({ rows: [] })} scope={SCOPE} now={NOW} />);
|
|
192
|
+
expect(await screen.findByText(/Nothing has been pulled out of your meetings yet/)).toBeInTheDocument();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('TrackerPage — running the extraction', () => {
|
|
197
|
+
const RESULT = {
|
|
198
|
+
documentsProcessed: 16,
|
|
199
|
+
fromRules: 24,
|
|
200
|
+
fromModel: 11,
|
|
201
|
+
created: 30,
|
|
202
|
+
merged: 5,
|
|
203
|
+
resolved: 2,
|
|
204
|
+
needsReview: [{ commitmentId: 'commit:open1', documentId: 'doc_4', quote: '2148 came back from the inventor' }],
|
|
205
|
+
droppedOutOfScope: 0,
|
|
206
|
+
provider: { provider: 'anthropic', model: 'claude-sonnet-4-6', live: true },
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
it('reports what the run did, and what the MODEL added over the rules', async () => {
|
|
210
|
+
const extract = vi.fn(async () => RESULT);
|
|
211
|
+
render(<TrackerPage api={makeApi({ extract })} scope={SCOPE} now={NOW} />);
|
|
212
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
213
|
+
await userEvent.click(screen.getByRole('button', { name: 'Pull from meetings' }));
|
|
214
|
+
|
|
215
|
+
expect(extract).toHaveBeenCalledWith({ scope: SCOPE });
|
|
216
|
+
expect(await screen.findByText(/Read 16 meetings/)).toBeInTheDocument();
|
|
217
|
+
// The measurement of whether the model call is worth paying for, on screen.
|
|
218
|
+
expect(screen.getByText(/24 came from the meetings’ own next-step lists, 11 were read/)).toBeInTheDocument();
|
|
219
|
+
expect(screen.getByText(/anthropic · claude-sonnet-4-6/)).toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('surfaces weak resolution matches instead of quietly closing things', async () => {
|
|
223
|
+
render(<TrackerPage api={makeApi({ extract: async () => RESULT })} scope={SCOPE} now={NOW} />);
|
|
224
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
225
|
+
await userEvent.click(screen.getByRole('button', { name: 'Pull from meetings' }));
|
|
226
|
+
|
|
227
|
+
expect(await screen.findByText(/Nothing was closed automatically/)).toBeInTheDocument();
|
|
228
|
+
expect(screen.getByText(/2148 came back from the inventor/)).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('reports a leaking data API rather than absorbing it', async () => {
|
|
232
|
+
render(
|
|
233
|
+
<TrackerPage
|
|
234
|
+
api={makeApi({ extract: async () => ({ ...RESULT, droppedOutOfScope: 3 }) })}
|
|
235
|
+
scope={SCOPE}
|
|
236
|
+
now={NOW}
|
|
237
|
+
/>,
|
|
238
|
+
);
|
|
239
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
240
|
+
await userEvent.click(screen.getByRole('button', { name: 'Pull from meetings' }));
|
|
241
|
+
|
|
242
|
+
expect(await screen.findByText(/3 records from another\s+workspace were returned/)).toBeInTheDocument();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('hides the control entirely on a read-only deployment', async () => {
|
|
246
|
+
render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
|
|
247
|
+
await screen.findByText('Chase the inventor for comments on 2148');
|
|
248
|
+
expect(screen.queryByRole('button', { name: 'Pull from meetings' })).not.toBeInTheDocument();
|
|
249
|
+
});
|
|
250
|
+
});
|
package/src/components/index.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* WHAT IT RENDERS, in the order the job actually happens:
|
|
11
11
|
*
|
|
12
|
-
* 1. connected accounts, each saying what it is and whether it is healthy
|
|
12
|
+
* 1. connected accounts, each saying what it is and whether it is healthy —
|
|
13
|
+
* and each MAINTAINABLE: test it, replace its key, disconnect it
|
|
13
14
|
* 2. the catalog — everything connectable, including rows that cannot load
|
|
14
15
|
* 3. per connection: DISCOVER what the credential reaches -> WIRE a choice ->
|
|
15
16
|
* CONFIRM the derived config -> FIRE it, and the run history of both legs
|
|
@@ -18,6 +19,14 @@
|
|
|
18
19
|
* and the CONFIRM step exists because a derived URL is a guess about the
|
|
19
20
|
* vendor's own settings until a human agrees with it.
|
|
20
21
|
*
|
|
22
|
+
* THE MAINTENANCE CONTROLS IN STEP 1 ARE NOT A DETAIL (bd startsim-768w.22.24).
|
|
23
|
+
* Without them a card is a dead end in both directions: an admin with a rotated
|
|
24
|
+
* vendor key cannot install it, and an admin who is done with a vendor cannot
|
|
25
|
+
* remove it. Both endpoints existed and worked; nothing rendered them, so the
|
|
26
|
+
* only control that looked like it would take a new key was the connect form —
|
|
27
|
+
* which makes a second card, not a replacement, and collided on a unique
|
|
28
|
+
* constraint that reached a live foundry as a Django HTML 500.
|
|
29
|
+
*
|
|
21
30
|
* NOTHING HERE NAMES A VENDOR. Every difference between two vendors arrives as
|
|
22
31
|
* data — the field schema, the capability list, the choice rows and their
|
|
23
32
|
* reasons. A `if (connector === ...)` in this file would undo the DB-driven
|
|
@@ -135,12 +144,13 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
135
144
|
api={api}
|
|
136
145
|
foundrySlug={foundrySlug}
|
|
137
146
|
connection={connection}
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// account connected before the picker existed
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
// The CONNECTOR's own catalog row. It carries the capability list,
|
|
148
|
+
// so the card can offer a capability this connection does not have
|
|
149
|
+
// yet — the only way an account connected before the picker existed
|
|
150
|
+
// ever gets one — and the credential schema, so replacing the key
|
|
151
|
+
// renders the same schema-driven form the connect path does rather
|
|
152
|
+
// than a bespoke "paste your key" box per vendor.
|
|
153
|
+
entry={catalog.find((row) => row.key === connection.connector_key)}
|
|
144
154
|
canManage={permissions.manage}
|
|
145
155
|
classNames={cn}
|
|
146
156
|
onChanged={reload}
|
|
@@ -156,6 +166,11 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
156
166
|
api={api}
|
|
157
167
|
foundrySlug={foundrySlug}
|
|
158
168
|
entry={entry}
|
|
169
|
+
// What this foundry ALREADY has on this connector. A second vendor
|
|
170
|
+
// account is legitimate and stays possible; an admin who is really
|
|
171
|
+
// trying to install a new key is not, and they are the ones this
|
|
172
|
+
// form used to swallow.
|
|
173
|
+
existing={connections.filter((c) => c.connector_key === entry.key)}
|
|
159
174
|
canManage={permissions.manage}
|
|
160
175
|
classNames={cn}
|
|
161
176
|
onConnected={reload}
|
|
@@ -240,11 +255,13 @@ function orderCapabilities(declared: string[], selected: Iterable<string>): stri
|
|
|
240
255
|
// --------------------------------------------------------------------------
|
|
241
256
|
|
|
242
257
|
function CatalogCard({
|
|
243
|
-
api, foundrySlug, entry, canManage, classNames: cn, onConnected,
|
|
258
|
+
api, foundrySlug, entry, existing, canManage, classNames: cn, onConnected,
|
|
244
259
|
}: {
|
|
245
260
|
api: ConnectionsApi;
|
|
246
261
|
foundrySlug: string;
|
|
247
262
|
entry: ConnectorCatalogEntry;
|
|
263
|
+
/** Cards this foundry already holds on this connector. */
|
|
264
|
+
existing: Connection[];
|
|
248
265
|
canManage: boolean;
|
|
249
266
|
classNames: typeof DEFAULTS;
|
|
250
267
|
onConnected: () => void;
|
|
@@ -325,6 +342,23 @@ function CatalogCard({
|
|
|
325
342
|
|
|
326
343
|
{open ? (
|
|
327
344
|
<div className="mt-4 space-y-3">
|
|
345
|
+
{/* SAID BEFORE THE KEY IS PASTED, not after the server refuses.
|
|
346
|
+
Connecting again makes a SECOND account, which is legitimate and
|
|
347
|
+
stays available — but it is almost never what an admin holding a
|
|
348
|
+
rotated key wants, and it was the only thing this page offered
|
|
349
|
+
them. The server now answers the indistinguishable case with a 409
|
|
350
|
+
naming the card; this catches it a round trip earlier. */}
|
|
351
|
+
{existing.length > 0 ? (
|
|
352
|
+
<p className={cn.muted} data-already-connected={entry.key}>
|
|
353
|
+
Already connected as{' '}
|
|
354
|
+
<strong className="text-gray-900">
|
|
355
|
+
{existing.map((c) => c.name).join(', ')}
|
|
356
|
+
</strong>
|
|
357
|
+
. To install a new key, use <em>Replace key</em> on that
|
|
358
|
+
connection — it is tested before it replaces the working one.
|
|
359
|
+
Connecting here adds a SECOND account.
|
|
360
|
+
</p>
|
|
361
|
+
) : null}
|
|
328
362
|
<CredentialSchemaForm
|
|
329
363
|
fields={fields}
|
|
330
364
|
values={values}
|
|
@@ -375,17 +409,119 @@ function CatalogCard({
|
|
|
375
409
|
);
|
|
376
410
|
}
|
|
377
411
|
|
|
412
|
+
// --------------------------------------------------------------------------
|
|
413
|
+
// replacing the key on a connection that already exists
|
|
414
|
+
// --------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* ROTATION, rendered from the same schema as the connect form.
|
|
418
|
+
*
|
|
419
|
+
* Two things make this safe to put in front of a non-engineer, and both are
|
|
420
|
+
* properties of the endpoint rather than of this component:
|
|
421
|
+
*
|
|
422
|
+
* * the pasted credential is run through `check()` against the vendor BEFORE
|
|
423
|
+
* anything is written, so a wrong key changes nothing and the working
|
|
424
|
+
* connection stays up. That is said in the copy, because someone about to
|
|
425
|
+
* paste a production key is entitled to know it;
|
|
426
|
+
* * a PARTIAL map is legal. A connector with a url + a key + a select takes
|
|
427
|
+
* just the key here, and the stored values fill in the rest — so replacing
|
|
428
|
+
* a secret never means re-typing the settings around it.
|
|
429
|
+
*
|
|
430
|
+
* Only the CREDENTIAL fields render. `config` is not a credential and is edited
|
|
431
|
+
* through the connection's own settings; mixing them here would make a
|
|
432
|
+
* rotation quietly a config change.
|
|
433
|
+
*/
|
|
434
|
+
function ReplaceKeyForm({
|
|
435
|
+
api, foundrySlug, connection, entry, classNames: cn, onDone,
|
|
436
|
+
}: {
|
|
437
|
+
api: ConnectionsApi;
|
|
438
|
+
foundrySlug: string;
|
|
439
|
+
connection: Connection;
|
|
440
|
+
entry?: ConnectorCatalogEntry;
|
|
441
|
+
classNames: typeof DEFAULTS;
|
|
442
|
+
onDone: () => void;
|
|
443
|
+
}) {
|
|
444
|
+
const [values, setValues] = React.useState<Record<string, string>>({});
|
|
445
|
+
const [busy, setBusy] = React.useState(false);
|
|
446
|
+
const [error, setError] = React.useState('');
|
|
447
|
+
|
|
448
|
+
const fields = schemaFields(entry?.credential_schema);
|
|
449
|
+
const warnings = patternWarnings(fields, values);
|
|
450
|
+
// NOT `missingRequired`. A rotation is partial by design — the whole point is
|
|
451
|
+
// to replace one secret without re-typing the fields around it — so the gate
|
|
452
|
+
// is "something was actually typed", and the server decides the rest.
|
|
453
|
+
const submitted = React.useMemo(() => {
|
|
454
|
+
const out: Record<string, unknown> = {};
|
|
455
|
+
for (const spec of fields) {
|
|
456
|
+
const value = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
|
|
457
|
+
if (value) out[spec.key] = value;
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
}, [fields, values]);
|
|
461
|
+
const empty = Object.keys(submitted).length === 0;
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
<div className="mt-3 space-y-3 border-t pt-3" data-replace-key={connection.id}>
|
|
465
|
+
<p className={cn.muted}>
|
|
466
|
+
The new key is tested against the vendor before it replaces the current
|
|
467
|
+
one. If it is rejected, nothing changes and this connection keeps
|
|
468
|
+
working on the key it has.
|
|
469
|
+
</p>
|
|
470
|
+
{fields.length === 0 ? (
|
|
471
|
+
<p className={cn.muted}>
|
|
472
|
+
This connector holds no credential of ours, so there is nothing to
|
|
473
|
+
replace.
|
|
474
|
+
</p>
|
|
475
|
+
) : (
|
|
476
|
+
<CredentialSchemaForm
|
|
477
|
+
fields={fields}
|
|
478
|
+
values={values}
|
|
479
|
+
warnings={warnings}
|
|
480
|
+
idPrefix={`replace-${connection.id}`}
|
|
481
|
+
onChange={(key, value) => {
|
|
482
|
+
const spec = fields.find((f) => f.key === key);
|
|
483
|
+
setValues((prev) => ({
|
|
484
|
+
...prev, [spec && isSecret(spec) ? `${key}__draft` : key]: value,
|
|
485
|
+
}));
|
|
486
|
+
}}
|
|
487
|
+
/>
|
|
488
|
+
)}
|
|
489
|
+
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
className={cn.button}
|
|
493
|
+
disabled={busy || empty}
|
|
494
|
+
onClick={() => {
|
|
495
|
+
setBusy(true);
|
|
496
|
+
setError('');
|
|
497
|
+
api.rotateCredential(foundrySlug, connection.id, submitted)
|
|
498
|
+
.then(() => { setValues({}); onDone(); })
|
|
499
|
+
// The server's typed answer, verbatim: it already scrubbed itself of
|
|
500
|
+
// the values just submitted, and "the vendor rejected this key" is
|
|
501
|
+
// not something the browser could work out on its own.
|
|
502
|
+
.catch((err: unknown) => setError(
|
|
503
|
+
(err as { message?: string })?.message || 'Could not replace the key.'))
|
|
504
|
+
.finally(() => setBusy(false));
|
|
505
|
+
}}
|
|
506
|
+
>
|
|
507
|
+
{busy ? 'Testing…' : 'Test and replace'}
|
|
508
|
+
</button>
|
|
509
|
+
</div>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
378
513
|
// --------------------------------------------------------------------------
|
|
379
514
|
// a connected account: discover -> wire -> confirm -> fire
|
|
380
515
|
// --------------------------------------------------------------------------
|
|
381
516
|
|
|
382
517
|
function ConnectionCard({
|
|
383
|
-
api, foundrySlug, connection,
|
|
518
|
+
api, foundrySlug, connection, entry, canManage, classNames: cn, onChanged,
|
|
384
519
|
}: {
|
|
385
520
|
api: ConnectionsApi;
|
|
386
521
|
foundrySlug: string;
|
|
387
522
|
connection: Connection;
|
|
388
|
-
|
|
523
|
+
/** This connector's catalog row: its capabilities AND its field schema. */
|
|
524
|
+
entry?: ConnectorCatalogEntry;
|
|
389
525
|
canManage: boolean;
|
|
390
526
|
classNames: typeof DEFAULTS;
|
|
391
527
|
onChanged: () => void;
|
|
@@ -396,7 +532,10 @@ function ConnectionCard({
|
|
|
396
532
|
const [busy, setBusy] = React.useState('');
|
|
397
533
|
const [error, setError] = React.useState('');
|
|
398
534
|
const [notice, setNotice] = React.useState('');
|
|
535
|
+
const [replacing, setReplacing] = React.useState(false);
|
|
536
|
+
const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
|
|
399
537
|
|
|
538
|
+
const declared = entry?.capabilities;
|
|
400
539
|
const enabled = React.useMemo(
|
|
401
540
|
() => connection.capabilities_enabled ?? [], [connection.capabilities_enabled]);
|
|
402
541
|
// The catalog's declaration first, then anything this connection holds that
|
|
@@ -444,7 +583,7 @@ function ConnectionCard({
|
|
|
444
583
|
) : null}
|
|
445
584
|
</div>
|
|
446
585
|
{canManage ? (
|
|
447
|
-
<div className="flex gap-2">
|
|
586
|
+
<div className="flex flex-wrap justify-end gap-2">
|
|
448
587
|
<button
|
|
449
588
|
type="button"
|
|
450
589
|
className={cn.button}
|
|
@@ -457,6 +596,21 @@ function ConnectionCard({
|
|
|
457
596
|
>
|
|
458
597
|
Test
|
|
459
598
|
</button>
|
|
599
|
+
{/* THE CONTROL THIS PAGE WAS MISSING. A vendor key gets rotated —
|
|
600
|
+
that is normal operations, not an exception — and the only place
|
|
601
|
+
to put the new one was a form that creates a second account. */}
|
|
602
|
+
<button
|
|
603
|
+
type="button"
|
|
604
|
+
className={cn.button}
|
|
605
|
+
disabled={Boolean(busy)}
|
|
606
|
+
onClick={() => {
|
|
607
|
+
setConfirmingDisconnect(false);
|
|
608
|
+
setError('');
|
|
609
|
+
setReplacing((v) => !v);
|
|
610
|
+
}}
|
|
611
|
+
>
|
|
612
|
+
{replacing ? 'Cancel' : 'Replace key'}
|
|
613
|
+
</button>
|
|
460
614
|
<button
|
|
461
615
|
type="button"
|
|
462
616
|
className={cn.button}
|
|
@@ -470,10 +624,74 @@ function ConnectionCard({
|
|
|
470
624
|
>
|
|
471
625
|
{busy === 'discover' ? 'Loading…' : 'Workflows'}
|
|
472
626
|
</button>
|
|
627
|
+
<button
|
|
628
|
+
type="button"
|
|
629
|
+
className={cn.button}
|
|
630
|
+
disabled={Boolean(busy) || confirmingDisconnect}
|
|
631
|
+
onClick={() => {
|
|
632
|
+
setReplacing(false);
|
|
633
|
+
setError('');
|
|
634
|
+
setConfirmingDisconnect(true);
|
|
635
|
+
}}
|
|
636
|
+
>
|
|
637
|
+
Disconnect
|
|
638
|
+
</button>
|
|
473
639
|
</div>
|
|
474
640
|
) : null}
|
|
475
641
|
</div>
|
|
476
642
|
|
|
643
|
+
{replacing ? (
|
|
644
|
+
<ReplaceKeyForm
|
|
645
|
+
api={api}
|
|
646
|
+
foundrySlug={foundrySlug}
|
|
647
|
+
connection={connection}
|
|
648
|
+
entry={entry}
|
|
649
|
+
classNames={cn}
|
|
650
|
+
onDone={() => { setReplacing(false); setNotice('New key installed.'); onChanged(); }}
|
|
651
|
+
/>
|
|
652
|
+
) : null}
|
|
653
|
+
|
|
654
|
+
{confirmingDisconnect ? (
|
|
655
|
+
<div className="mt-3 space-y-2 border-t pt-3" role="group"
|
|
656
|
+
aria-label="Confirm disconnect">
|
|
657
|
+
{/* Disconnect is one sentence to an admin and four writes to the
|
|
658
|
+
server, three of which cannot be undone by reconnecting: the
|
|
659
|
+
tokens this vendor holds against us are revoked and the stored
|
|
660
|
+
credential is deleted. Naming them is the difference between a
|
|
661
|
+
control someone uses deliberately and one they regret. */}
|
|
662
|
+
<p className={cn.muted}>
|
|
663
|
+
Disconnecting <strong className="text-gray-900">{connection.name}</strong>{' '}
|
|
664
|
+
will stop its scheduled fetches, stop anything it sends out, revoke
|
|
665
|
+
every token issued for it and delete the stored key. The card stays
|
|
666
|
+
here, marked disabled, and you can connect it again later with a new
|
|
667
|
+
key.
|
|
668
|
+
</p>
|
|
669
|
+
<div className="flex gap-2">
|
|
670
|
+
<button
|
|
671
|
+
type="button"
|
|
672
|
+
className={cn.button}
|
|
673
|
+
disabled={Boolean(busy)}
|
|
674
|
+
onClick={() => void guard('disconnect', async () => {
|
|
675
|
+
await api.disconnect(foundrySlug, connection.id);
|
|
676
|
+
setConfirmingDisconnect(false);
|
|
677
|
+
setNotice('Disconnected.');
|
|
678
|
+
onChanged();
|
|
679
|
+
})}
|
|
680
|
+
>
|
|
681
|
+
{busy === 'disconnect' ? 'Disconnecting…' : 'Yes, disconnect'}
|
|
682
|
+
</button>
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
className={cn.button}
|
|
686
|
+
disabled={Boolean(busy)}
|
|
687
|
+
onClick={() => setConfirmingDisconnect(false)}
|
|
688
|
+
>
|
|
689
|
+
Cancel
|
|
690
|
+
</button>
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
693
|
+
) : null}
|
|
694
|
+
|
|
477
695
|
{/* THE FIX FOR THE CARDS THAT ALREADY EXIST. A connection written before
|
|
478
696
|
the connect form asked holds an empty list and can do nothing; the
|
|
479
697
|
connect form can never reach it. Turning `poll` on here is what makes
|