@flame0510/project-aether 1.1.15 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/credentials/[id]/sync/route.ts +3 -3
- package/app/api/credentials/detect/route.ts +126 -176
- package/app/api/credentials/route.ts +3 -0
- package/app/components/Skeleton.tsx +132 -0
- package/app/credentials/PageClient.tsx +460 -139
- package/app/credentials/loading.tsx +19 -5
- package/docs/ARCHITECTURE.md +78 -31
- package/docs/FRONTEND-ARCHITECTURE.md +13 -5
- package/docs/REV4A.md +23 -14
- package/docs/dev/API-REFERENCE.md +356 -184
- package/docs/dev/DATABASE.md +8 -3
- package/docs/dev/GATEWAY.md +9 -7
- package/docs/dev/PROVIDERS.md +22 -35
- package/docs/rag/GLOSSARY.md +8 -8
- package/docs/rag/REV4A-OVERVIEW.md +7 -10
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -6
- package/lib/credentials/delivery.ts +212 -119
- package/lib/credentials/detect.ts +229 -97
- package/lib/credentials/providers.ts +38 -7
- package/lib/credentials/vault.ts +78 -13
- package/lib/docker-exec.ts +50 -14
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import { Page, PageHeader, Button, Modal, Input, Pill } from '@/app/components/ui';
|
|
5
|
+
import { CredentialCardsSkeleton, AgentSyncRowsSkeleton } from '@/app/components/Skeleton';
|
|
5
6
|
import type { CredentialProfile } from '@/lib/credentials/vault';
|
|
6
7
|
import type { CredentialProviderDef } from '@/lib/credentials/providers';
|
|
7
8
|
|
|
@@ -27,13 +28,21 @@ const PROVIDER_COLOR: Record<string, string> = {
|
|
|
27
28
|
notion: '#050505',
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Read a mutation's JSON body, failing on anything that is not a 2xx.
|
|
33
|
+
*
|
|
34
|
+
* Testing `json.error` alone trusted the response to be JSON and to carry that
|
|
35
|
+
* field. A proxy 502, or Next's own HTML error page, parses to `{}` — no
|
|
36
|
+
* `error` — so the check passed and the caller announced a success for a
|
|
37
|
+
* request that never happened. The credentials routes do return `{ error }` on
|
|
38
|
+
* every path they control, which is exactly why this went unnoticed: the gap is
|
|
39
|
+
* everything in front of them.
|
|
40
|
+
*/
|
|
41
|
+
async function readMutation<T>(res: Response): Promise<T> {
|
|
42
|
+
const json = await res.json().catch(() => ({})) as T & { error?: string };
|
|
43
|
+
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
|
|
44
|
+
if (json.error) throw new Error(json.error);
|
|
45
|
+
return json;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
function formatRelative(ts: number | null): string {
|
|
@@ -69,43 +78,190 @@ export default function CredentialsPageClient() {
|
|
|
69
78
|
trelloCliInstalled: boolean;
|
|
70
79
|
notionLoggedIn: boolean;
|
|
71
80
|
matchedProfiles?: { profileId: string; providerId: string; label: string }[];
|
|
81
|
+
installedProviders?: string[];
|
|
82
|
+
/** Present instead of the fields above when the probe failed for this
|
|
83
|
+
* container. Every other property is absent, so it must be checked first. */
|
|
84
|
+
error?: string;
|
|
72
85
|
}[]>([]);
|
|
73
86
|
|
|
74
87
|
const [configureProviderId, setConfigureProviderId] = useState<string | null>(null);
|
|
75
88
|
const [editProfile, setEditProfile] = useState<CredentialProfile | null>(null);
|
|
76
89
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
77
90
|
const [toast, setToast] = useState<string | null>(null);
|
|
91
|
+
const [detectRefreshing, setDetectRefreshing] = useState(false);
|
|
92
|
+
|
|
93
|
+
// Whether detect has ever answered, success or failure. A one-way latch, and
|
|
94
|
+
// deliberately not reset per call: every mutation fires a single page-wide
|
|
95
|
+
// `loadDetect()`, so clearing it each time would hide every delete button on
|
|
96
|
+
// the page for the duration of any one sync.
|
|
97
|
+
const [detectLoaded, setDetectLoaded] = useState(false);
|
|
98
|
+
|
|
99
|
+
// Distinct from `detectLoaded`, which only records that the call finished.
|
|
100
|
+
// A failed call finishes too, leaving `detectData` empty — indistinguishable
|
|
101
|
+
// from a fleet that genuinely holds nothing, which is what unlocked Delete on
|
|
102
|
+
// every credential the moment the session expired or Docker went down.
|
|
103
|
+
const [detectFailed, setDetectFailed] = useState(false);
|
|
104
|
+
|
|
105
|
+
// One key identifies the single mutation allowed in flight, e.g.
|
|
106
|
+
// 'delete:cred_123' or 'sync:cred_123:agent_ab'. Replaces the scattered
|
|
107
|
+
// per-action booleans: the previous `syncingContainer` held only a container
|
|
108
|
+
// name, but the per-agent rows are rendered once per profile, so syncing one
|
|
109
|
+
// profile put every other profile's button for that container into a
|
|
110
|
+
// spinner too. Including the profile id in the key removes that entirely.
|
|
111
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
112
|
+
|
|
113
|
+
// The ref is the authoritative guard, the state only drives rendering.
|
|
114
|
+
// `busy` is read from the render that produced the click, so two clicks
|
|
115
|
+
// landing before React commits the disabled button would both see null and
|
|
116
|
+
// both fire. A ref is updated synchronously, so the second one cannot.
|
|
117
|
+
const busyRef = useRef<string | null>(null);
|
|
118
|
+
const claimBusy = (key: string): boolean => {
|
|
119
|
+
if (busyRef.current) return false;
|
|
120
|
+
busyRef.current = key;
|
|
121
|
+
setBusy(key);
|
|
122
|
+
return true;
|
|
123
|
+
};
|
|
124
|
+
const releaseBusy = () => {
|
|
125
|
+
busyRef.current = null;
|
|
126
|
+
setBusy(null);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Held so a second message cannot be cleared by the first one's timer: two
|
|
130
|
+
// syncs 2.5s apart left the later toast on screen for half a second before an
|
|
131
|
+
// older, unowned timeout fired and wiped it.
|
|
132
|
+
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
133
|
+
const showToast = (msg: string) => {
|
|
134
|
+
setToast(msg);
|
|
135
|
+
if (toastTimer.current) clearTimeout(toastTimer.current);
|
|
136
|
+
toastTimer.current = setTimeout(() => setToast(null), 3000);
|
|
137
|
+
};
|
|
138
|
+
useEffect(() => () => {
|
|
139
|
+
if (toastTimer.current) clearTimeout(toastTimer.current);
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Apply a confirmed sync/de-sync to the detected state right away.
|
|
144
|
+
*
|
|
145
|
+
* The Sync / De-sync label is driven by `matchedProfiles`, which only detect
|
|
146
|
+
* fills in — and detect takes seconds. Waiting for it left the button live
|
|
147
|
+
* again but still showing the state from before the click, so it invited a
|
|
148
|
+
* second, pointless press. This is not a guess: the server has already
|
|
149
|
+
* confirmed the change, so the new state is known. The detect refresh that
|
|
150
|
+
* follows just corroborates it.
|
|
151
|
+
*/
|
|
152
|
+
const applyMatchLocally = (
|
|
153
|
+
profile: CredentialProfile,
|
|
154
|
+
container: string,
|
|
155
|
+
matched: boolean,
|
|
156
|
+
) => {
|
|
157
|
+
setDetectData((prev) => prev.map((d) => {
|
|
158
|
+
if (d.container !== container) return d;
|
|
159
|
+
// Keyed on the provider, not the profile. A container holds one credential
|
|
160
|
+
// per provider, and both operations act on the provider as a whole:
|
|
161
|
+
// delivery cleans it before writing, de-sync removes its config outright.
|
|
162
|
+
// So no sibling profile can survive either one, and filtering by profile id
|
|
163
|
+
// left the previous holder still marked as installed — two rows showing a
|
|
164
|
+
// green tick, each offering a De-sync that would take the other's token.
|
|
165
|
+
const without = (d.matchedProfiles ?? []).filter((m) => m.providerId !== profile.providerId);
|
|
166
|
+
|
|
167
|
+
// `installedProviders` has to move together with `matchedProfiles`.
|
|
168
|
+
// Updating only the latter left the row reading the two against each
|
|
169
|
+
// other — provider still listed as installed, no profile accounting for
|
|
170
|
+
// it — which is exactly the ◆ "unknown credential" state, held for the
|
|
171
|
+
// whole seconds-long gap until detect answered and cleared it. The de-sync
|
|
172
|
+
// path removes the provider's credential from the container outright, so
|
|
173
|
+
// dropping it here is what the container actually looks like; the guard
|
|
174
|
+
// keeps it if some other profile of the same provider is still matched.
|
|
175
|
+
// `without` can no longer hold this provider, so there is nothing left to
|
|
176
|
+
// check before dropping it: the guard that used to stand here was dead the
|
|
177
|
+
// moment the filter above became provider-keyed.
|
|
178
|
+
const installed = new Set(d.installedProviders ?? []);
|
|
179
|
+
if (matched) installed.add(profile.providerId);
|
|
180
|
+
else installed.delete(profile.providerId);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
...d,
|
|
184
|
+
matchedProfiles: matched
|
|
185
|
+
? [...without, { profileId: profile.id, providerId: profile.providerId, label: profile.label }]
|
|
186
|
+
: without,
|
|
187
|
+
installedProviders: [...installed],
|
|
188
|
+
};
|
|
189
|
+
}));
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The two endpoints are split because they cost wildly different amounts and
|
|
194
|
+
* carry different things. /api/credentials is a single SQLite read (~3ms) and
|
|
195
|
+
* holds everything the cards need; /api/credentials/detect shells into every
|
|
196
|
+
* agent container and takes seconds, and only feeds the per-agent rows.
|
|
197
|
+
*
|
|
198
|
+
* Keeping them apart is what lets a create or a delete show up immediately:
|
|
199
|
+
* the mutation paths refresh the cheap one and let the expensive one catch up
|
|
200
|
+
* behind, instead of making the user wait on work their change did not need.
|
|
201
|
+
*/
|
|
202
|
+
const loadProfiles = useCallback(async (signal?: AbortSignal) => {
|
|
203
|
+
const res = await fetch('/api/credentials', { signal });
|
|
204
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
205
|
+
setData(await res.json() as ApiData);
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
// Only the most recently started detect may apply its answer. Two can easily
|
|
209
|
+
// overlap: a mutation fires one without awaiting it, then releases the lock
|
|
210
|
+
// within milliseconds, so a second mutation starts another while the first is
|
|
211
|
+
// still running. Whichever returned last used to win — often the older one,
|
|
212
|
+
// which knows nothing of the newer change.
|
|
213
|
+
const detectSeq = useRef(0);
|
|
214
|
+
const detectAbortRef = useRef<AbortController | null>(null);
|
|
215
|
+
|
|
216
|
+
const loadDetect = useCallback(async (signal?: AbortSignal) => {
|
|
217
|
+
// Cancel any detect still running: it costs seconds of work inside every
|
|
218
|
+
// agent container, and its answer would be discarded by the sequence guard
|
|
219
|
+
// below anyway. The guard stays — abortion frees the work, the guard is what
|
|
220
|
+
// makes a late reply harmless.
|
|
221
|
+
detectAbortRef.current?.abort();
|
|
222
|
+
const controller = new AbortController();
|
|
223
|
+
detectAbortRef.current = controller;
|
|
224
|
+
signal?.addEventListener('abort', () => controller.abort(), { once: true });
|
|
225
|
+
|
|
226
|
+
const seq = ++detectSeq.current;
|
|
227
|
+
setDetectRefreshing(true);
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetch('/api/credentials/detect', { signal: controller.signal });
|
|
230
|
+
// An error response is still valid JSON, so without this check a 401 or a
|
|
231
|
+
// 500 body flowed through `json.agents ?? []` and became an empty fleet —
|
|
232
|
+
// reported as success. The page then stated, as fact, that no agent holds
|
|
233
|
+
// any credential. Throwing here keeps whatever data was already on screen.
|
|
234
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
235
|
+
const json = await res.json() as { agents?: unknown[] };
|
|
236
|
+
if (seq === detectSeq.current) {
|
|
237
|
+
setDetectData((json.agents ?? []) as typeof detectData);
|
|
238
|
+
setDetectFailed(false);
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// Only the newest call may report; a superseded one losing is not a
|
|
242
|
+
// failure, and an abort is our own doing.
|
|
243
|
+
if (seq === detectSeq.current && !controller.signal.aborted) setDetectFailed(true);
|
|
244
|
+
// Best-effort: every card renders without this. Detect used to be awaited
|
|
245
|
+
// inside the page-wide try, so one failure there blanked a page whose real
|
|
246
|
+
// data had already arrived.
|
|
247
|
+
} finally {
|
|
248
|
+
if (!controller.signal.aborted) setDetectLoaded(true);
|
|
249
|
+
// A superseded call must not clear the flag — the newer one is still
|
|
250
|
+
// running, and the rows it feeds are still stale.
|
|
251
|
+
if (seq === detectSeq.current && !controller.signal.aborted) setDetectRefreshing(false);
|
|
252
|
+
}
|
|
253
|
+
}, []);
|
|
78
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Only the cheap call gates the first paint. Detect shells into every agent
|
|
257
|
+
* container and takes seconds; awaiting it here held back the whole page —
|
|
258
|
+
* cards, stored profiles, the Add buttons — for work none of them need. It is
|
|
259
|
+
* started without being awaited, and the per-agent rows show a skeleton until
|
|
260
|
+
* it lands.
|
|
261
|
+
*/
|
|
79
262
|
const load = useCallback(async (signal?: AbortSignal) => {
|
|
80
263
|
try {
|
|
81
|
-
|
|
82
|
-
fetch('/api/credentials', { signal }),
|
|
83
|
-
fetch('/api/credentials/detect', { signal }),
|
|
84
|
-
]);
|
|
85
|
-
if (!credsRes.ok) throw new Error(`HTTP ${credsRes.status}`);
|
|
86
|
-
const json = await credsRes.json() as ApiData;
|
|
87
|
-
const detectJson = await detectRes.json() as { agents?: {
|
|
88
|
-
container: string;
|
|
89
|
-
agentId: string;
|
|
90
|
-
displayName?: string;
|
|
91
|
-
githubLoggedIn: boolean;
|
|
92
|
-
githubUser: string;
|
|
93
|
-
githubCliInstalled: boolean;
|
|
94
|
-
vercelLoggedIn: boolean;
|
|
95
|
-
vercelUser: string;
|
|
96
|
-
vercelTeam: string;
|
|
97
|
-
vercelCliInstalled: boolean;
|
|
98
|
-
supabaseLoggedIn: boolean;
|
|
99
|
-
supabaseLinked: boolean;
|
|
100
|
-
supabaseProjectRef: string;
|
|
101
|
-
supabaseCliInstalled: boolean;
|
|
102
|
-
trelloLoggedIn: boolean;
|
|
103
|
-
trelloCliInstalled: boolean;
|
|
104
|
-
notionLoggedIn: boolean;
|
|
105
|
-
matchedProfiles?: { profileId: string; providerId: string; label: string }[];
|
|
106
|
-
}[] };
|
|
107
|
-
setData(json);
|
|
108
|
-
setDetectData(detectJson.agents ?? []);
|
|
264
|
+
await loadProfiles(signal);
|
|
109
265
|
setError(null);
|
|
110
266
|
} catch (e: unknown) {
|
|
111
267
|
if ((e as Error)?.name === 'AbortError') return;
|
|
@@ -113,7 +269,28 @@ export default function CredentialsPageClient() {
|
|
|
113
269
|
} finally {
|
|
114
270
|
if (!signal?.aborted) setLoading(false);
|
|
115
271
|
}
|
|
116
|
-
|
|
272
|
+
void loadDetect(signal);
|
|
273
|
+
}, [loadProfiles, loadDetect]);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Refresh after a create, edit or delete.
|
|
277
|
+
*
|
|
278
|
+
* Awaits only the cheap call, so the caller can keep its button spinning for
|
|
279
|
+
* the few milliseconds until the change is actually on screen, then closes.
|
|
280
|
+
* Detect is deliberately not awaited.
|
|
281
|
+
*
|
|
282
|
+
* Never rejects. The mutation has already succeeded by the time this runs, so
|
|
283
|
+
* letting a failed re-read propagate would surface as "save failed" on a saved
|
|
284
|
+
* credential — and invite the user to create it a second time.
|
|
285
|
+
*/
|
|
286
|
+
const refreshAfterChange = useCallback(async () => {
|
|
287
|
+
try {
|
|
288
|
+
await loadProfiles();
|
|
289
|
+
} catch (e: unknown) {
|
|
290
|
+
showToast(`Saved, but the list could not be refreshed: ${(e as Error).message}`);
|
|
291
|
+
}
|
|
292
|
+
void loadDetect();
|
|
293
|
+
}, [loadProfiles, loadDetect]);
|
|
117
294
|
|
|
118
295
|
useEffect(() => {
|
|
119
296
|
const controller = new AbortController();
|
|
@@ -121,75 +298,81 @@ export default function CredentialsPageClient() {
|
|
|
121
298
|
return () => controller.abort();
|
|
122
299
|
}, [load]);
|
|
123
300
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
const res = await fetch('/api/credentials/detect');
|
|
127
|
-
const json = await res.json() as { agents?: unknown[] };
|
|
128
|
-
setDetectData((json.agents ?? []) as typeof detectData);
|
|
129
|
-
} catch { /* best-effort */ }
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const showToast = (msg: string) => {
|
|
133
|
-
setToast(msg);
|
|
134
|
-
setTimeout(() => setToast(null), 3000);
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const [syncingContainer, setSyncingContainer] = useState<string | null>(null);
|
|
138
|
-
|
|
139
|
-
const handleSyncOne = async (profileId: string, container: string, label?: string) => {
|
|
140
|
-
setSyncingContainer(container);
|
|
301
|
+
const handleSyncOne = async (profile: CredentialProfile, container: string, label?: string) => {
|
|
302
|
+
if (!claimBusy(`sync:${profile.id}:${container}`)) return;
|
|
141
303
|
try {
|
|
142
|
-
const res = await fetch(`/api/credentials/${
|
|
304
|
+
const res = await fetch(`/api/credentials/${profile.id}/sync`, {
|
|
143
305
|
method: 'POST',
|
|
144
306
|
headers: { 'Content-Type': 'application/json' },
|
|
145
307
|
body: JSON.stringify({ containers: [container] }),
|
|
146
308
|
});
|
|
147
|
-
const json = await
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
309
|
+
const json = await readMutation<{ results?: { container: string; status: string }[] }>(res);
|
|
310
|
+
const ok = json.results?.[0]?.status === 'ok';
|
|
311
|
+
if (ok) applyMatchLocally(profile, container, true);
|
|
312
|
+
showToast(ok ? `Synced to ${label || container}` : 'Sync error');
|
|
151
313
|
} catch (e: unknown) {
|
|
152
314
|
showToast((e as Error).message);
|
|
153
315
|
} finally {
|
|
154
|
-
|
|
316
|
+
releaseBusy();
|
|
155
317
|
}
|
|
318
|
+
// Refreshed outside the busy window on purpose. Detect takes seconds, and
|
|
319
|
+
// `disabled={!!busy}` is page-wide, so awaiting it here left every button
|
|
320
|
+
// on the page dead for that whole time after each sync. The dimmed rows
|
|
321
|
+
// already say the detected state is being re-read.
|
|
322
|
+
void loadDetect();
|
|
156
323
|
};
|
|
157
324
|
|
|
158
|
-
const handleDeSyncOne = async (
|
|
159
|
-
|
|
325
|
+
const handleDeSyncOne = async (profile: CredentialProfile, container: string, label?: string) => {
|
|
326
|
+
if (!claimBusy(`desync:${profile.id}:${container}`)) return;
|
|
160
327
|
try {
|
|
161
|
-
const res = await fetch(`/api/credentials/${
|
|
328
|
+
const res = await fetch(`/api/credentials/${profile.id}/sync`, {
|
|
162
329
|
method: 'DELETE',
|
|
163
330
|
headers: { 'Content-Type': 'application/json' },
|
|
164
331
|
body: JSON.stringify({ container }),
|
|
165
332
|
});
|
|
166
|
-
|
|
167
|
-
|
|
333
|
+
await readMutation<{ ok?: boolean }>(res);
|
|
334
|
+
applyMatchLocally(profile, container, false);
|
|
168
335
|
showToast(`Removed from ${label || container}`);
|
|
169
|
-
await refreshDetectOnly();
|
|
170
336
|
} catch (e: unknown) {
|
|
171
337
|
showToast((e as Error).message);
|
|
172
338
|
} finally {
|
|
173
|
-
|
|
339
|
+
releaseBusy();
|
|
174
340
|
}
|
|
341
|
+
// Same reasoning as handleSyncOne above.
|
|
342
|
+
void loadDetect();
|
|
175
343
|
};
|
|
176
344
|
|
|
177
345
|
const handleDelete = async (id: string) => {
|
|
346
|
+
// Without this the confirm dialog stayed open and clickable for the whole
|
|
347
|
+
// request — `setDeleteId(null)` ran only after the await — so the button
|
|
348
|
+
// could be pressed repeatedly, firing a DELETE, a toast and a full reload
|
|
349
|
+
// each time.
|
|
350
|
+
if (!claimBusy(`delete:${id}`)) return;
|
|
178
351
|
try {
|
|
179
|
-
await fetch(`/api/credentials/${id}`, { method: 'DELETE' });
|
|
352
|
+
const res = await fetch(`/api/credentials/${id}`, { method: 'DELETE' });
|
|
353
|
+
await readMutation<Record<string, never>>(res);
|
|
180
354
|
setDeleteId(null);
|
|
181
355
|
showToast('Credential deleted');
|
|
182
|
-
|
|
356
|
+
await refreshAfterChange();
|
|
183
357
|
} catch (e: unknown) {
|
|
184
358
|
showToast((e as Error).message);
|
|
359
|
+
} finally {
|
|
360
|
+
releaseBusy();
|
|
185
361
|
}
|
|
186
362
|
};
|
|
187
363
|
|
|
188
364
|
if (loading) {
|
|
365
|
+
// The header carries the same description as the loaded state on purpose:
|
|
366
|
+
// omitting it here made the title block grow by one line the moment the
|
|
367
|
+
// data arrived, shifting the whole grid down.
|
|
189
368
|
return (
|
|
190
369
|
<Page>
|
|
191
|
-
<PageHeader
|
|
192
|
-
|
|
370
|
+
<PageHeader
|
|
371
|
+
eyebrow=""
|
|
372
|
+
title="Credentials"
|
|
373
|
+
description="Store third-party credentials once, sync to all agent containers."
|
|
374
|
+
/>
|
|
375
|
+
<CredentialCardsSkeleton />
|
|
193
376
|
</Page>
|
|
194
377
|
);
|
|
195
378
|
}
|
|
@@ -300,17 +483,79 @@ export default function CredentialsPageClient() {
|
|
|
300
483
|
</div>
|
|
301
484
|
</div>
|
|
302
485
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
|
303
|
-
<Button type="button" variant="ghost" size="sm" onClick={() => setEditProfile(p)}>Edit</Button>
|
|
304
|
-
{
|
|
305
|
-
|
|
486
|
+
<Button type="button" variant="ghost" size="sm" disabled={!!busy} onClick={() => setEditProfile(p)}>Edit</Button>
|
|
487
|
+
{/* Shown only once detect has actually answered AND reports no
|
|
488
|
+
match. `.some()` on an empty array is false, so before this
|
|
489
|
+
guard a fleet we simply had not heard from yet — or an error
|
|
490
|
+
response — read as "synced nowhere", offering a delete the UI
|
|
491
|
+
means to withhold for a credential that is in use. */}
|
|
492
|
+
{/* Withheld unless detect has answered for *every* agent and
|
|
493
|
+
none of them holds this credential. A container whose probe
|
|
494
|
+
failed reports no `matchedProfiles` at all, which read as
|
|
495
|
+
"not installed here" — so one unreachable agent was enough
|
|
496
|
+
to offer deleting a credential that may well be installed
|
|
497
|
+
on it, leaving it live in a container with no vault record. */}
|
|
498
|
+
{detectLoaded
|
|
499
|
+
&& !detectFailed
|
|
500
|
+
&& !detectData.some((d) => d.error)
|
|
501
|
+
&& !detectData.some((d) => d.matchedProfiles?.some((m) => m.profileId === p.id)) && (
|
|
502
|
+
<Button type="button" variant="danger" size="sm" disabled={!!busy} onClick={() => setDeleteId(p.id)}>×</Button>
|
|
306
503
|
)}
|
|
307
504
|
</div>
|
|
308
505
|
</div>
|
|
309
506
|
{/* Agent detect status — all containers shown for every provider */}
|
|
310
|
-
{
|
|
507
|
+
{/* Three states, kept distinct on purpose: not answered
|
|
508
|
+
yet (skeleton), answered with no agents (nothing), and
|
|
509
|
+
answered with agents (the rows). Collapsing the first
|
|
510
|
+
two would present "no agent holds this credential" as
|
|
511
|
+
fact while we are still asking. */}
|
|
512
|
+
{!detectLoaded && <AgentSyncRowsSkeleton />}
|
|
513
|
+
{/* Said out loud rather than rendered as an empty fleet.
|
|
514
|
+
Silence here reads as "no agent holds this", which is
|
|
515
|
+
the one conclusion a failed probe cannot support. */}
|
|
516
|
+
{detectLoaded && detectFailed && (
|
|
517
|
+
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--red)' }}>
|
|
518
|
+
Agent status unavailable
|
|
519
|
+
{detectData.length > 0 ? ' — showing the last known state' : ''}
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
{detectLoaded && detectData.length > 0 && (
|
|
311
523
|
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
312
524
|
{detectData.map((d) => {
|
|
525
|
+
// Checked before anything else: on an error entry every other field is
|
|
526
|
+
// undefined, which made `cliOk` falsy and had the row announce "CLI not
|
|
527
|
+
// installed" — sending the user to rebuild a base image over what is
|
|
528
|
+
// really a container that did not answer.
|
|
529
|
+
if (d.error) {
|
|
530
|
+
return (
|
|
531
|
+
<div key={d.container || d.agentId} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontFamily: 'var(--font-mono)' }}>
|
|
532
|
+
<span style={{ color: 'var(--red)', fontSize: 12, fontWeight: 700 }}>!</span>
|
|
533
|
+
<span style={{ color: 'var(--text-dim)', flex: 1 }}>
|
|
534
|
+
{d.displayName || d.agentId}
|
|
535
|
+
<span style={{ marginLeft: 6, color: 'var(--red)', fontSize: 10 }}>unreachable</span>
|
|
536
|
+
</span>
|
|
537
|
+
</div>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
313
541
|
const matchByProfile = d.matchedProfiles?.some((m) => m.profileId === p.id);
|
|
542
|
+
// A credential is installed for this provider, but it is not
|
|
543
|
+
// this profile's — and not any stored profile's either. It is
|
|
544
|
+
// what an edited-then-not-resynced credential looks like, and
|
|
545
|
+
// also a token installed by hand or left by a deleted profile.
|
|
546
|
+
// The slot holds someone else's credential. Syncing this
|
|
547
|
+
// profile evicts it, so the button has to say so — whether or
|
|
548
|
+
// not the vault can name the occupant.
|
|
549
|
+
const providerOccupied =
|
|
550
|
+
!matchByProfile
|
|
551
|
+
&& (d.installedProviders?.includes(p.providerId) ?? false);
|
|
552
|
+
// The vault CAN name it: another stored profile of the same
|
|
553
|
+
// provider matches. Not foreign, just not this one.
|
|
554
|
+
const heldByOther = providerOccupied
|
|
555
|
+
? (d.matchedProfiles ?? []).find((m) => m.providerId === p.providerId)
|
|
556
|
+
: undefined;
|
|
557
|
+
// Nobody accounts for it — the ◆ case, kept narrow.
|
|
558
|
+
const foreignCredential = providerOccupied && !heldByOther;
|
|
314
559
|
|
|
315
560
|
// Determine CLI installed status for this provider on this agent
|
|
316
561
|
const cliOk = isGithub
|
|
@@ -319,9 +564,11 @@ export default function CredentialsPageClient() {
|
|
|
319
564
|
? d.vercelCliInstalled
|
|
320
565
|
: isSupabase
|
|
321
566
|
? d.supabaseCliInstalled
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
567
|
+
// Trello and Notion are both used through their REST
|
|
568
|
+
// API, so there is no CLI whose absence could block
|
|
569
|
+
// them; `trelloCliInstalled` stays in the response for
|
|
570
|
+
// shape compatibility but no longer gates anything.
|
|
571
|
+
: true;
|
|
325
572
|
|
|
326
573
|
let extraInfo = '';
|
|
327
574
|
if (matchByProfile) {
|
|
@@ -347,14 +594,39 @@ export default function CredentialsPageClient() {
|
|
|
347
594
|
|
|
348
595
|
return (
|
|
349
596
|
<div key={d.container || d.agentId} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontFamily: 'var(--font-mono)' }}>
|
|
350
|
-
|
|
351
|
-
|
|
597
|
+
{/* Only the detected state fades while detect is
|
|
598
|
+
re-read — never the buttons. Dimming their
|
|
599
|
+
ancestor made them read as disabled while they
|
|
600
|
+
were still perfectly clickable. */}
|
|
601
|
+
<span style={{
|
|
602
|
+
color: matchByProfile
|
|
603
|
+
? 'var(--green)'
|
|
604
|
+
: foreignCredential ? 'var(--yellow)' : 'var(--text-dim)',
|
|
605
|
+
fontSize: 12, fontWeight: 700,
|
|
606
|
+
opacity: detectRefreshing ? 0.4 : 1,
|
|
607
|
+
transition: 'opacity 0.15s',
|
|
608
|
+
}}>
|
|
609
|
+
{matchByProfile ? '✓' : foreignCredential ? '◆' : '○'}
|
|
352
610
|
</span>
|
|
353
|
-
<span style={{
|
|
611
|
+
<span style={{
|
|
612
|
+
color: 'var(--text-dim)', flex: 1,
|
|
613
|
+
opacity: detectRefreshing ? 0.4 : 1,
|
|
614
|
+
transition: 'opacity 0.15s',
|
|
615
|
+
}}>
|
|
354
616
|
{d.displayName || d.agentId}
|
|
355
617
|
{extraInfo && (
|
|
356
618
|
<span style={{ marginLeft: 6, color: 'var(--green)', fontSize: 10 }}>{extraInfo}</span>
|
|
357
619
|
)}
|
|
620
|
+
{foreignCredential && (
|
|
621
|
+
<span style={{ marginLeft: 6, color: 'var(--yellow)', fontSize: 10 }}>
|
|
622
|
+
unknown credential
|
|
623
|
+
</span>
|
|
624
|
+
)}
|
|
625
|
+
{heldByOther && (
|
|
626
|
+
<span style={{ marginLeft: 6, color: 'var(--text-dim)', fontSize: 10 }}>
|
|
627
|
+
held by {heldByOther.label}
|
|
628
|
+
</span>
|
|
629
|
+
)}
|
|
358
630
|
{showCliMissing && (
|
|
359
631
|
<span style={{ marginLeft: 6, color: 'var(--red)', fontSize: 10 }}>CLI not installed</span>
|
|
360
632
|
)}
|
|
@@ -368,21 +640,26 @@ export default function CredentialsPageClient() {
|
|
|
368
640
|
<Button
|
|
369
641
|
variant='danger'
|
|
370
642
|
size='sm'
|
|
371
|
-
loading={
|
|
372
|
-
disabled={
|
|
373
|
-
onClick={() => handleDeSyncOne(p
|
|
643
|
+
loading={busy === `desync:${p.id}:${d.container}`}
|
|
644
|
+
disabled={!!busy}
|
|
645
|
+
onClick={() => handleDeSyncOne(p, d.container, d.displayName || d.agentId)}
|
|
374
646
|
>
|
|
375
|
-
De-sync
|
|
647
|
+
{busy === `desync:${p.id}:${d.container}` ? 'Removing…' : 'De-sync'}
|
|
376
648
|
</Button>
|
|
377
649
|
) : (
|
|
378
650
|
<Button
|
|
379
651
|
variant='primary'
|
|
380
652
|
size='sm'
|
|
381
|
-
loading={
|
|
382
|
-
disabled={
|
|
383
|
-
|
|
653
|
+
loading={busy === `sync:${p.id}:${d.container}`}
|
|
654
|
+
disabled={!!busy}
|
|
655
|
+
title={heldByOther
|
|
656
|
+
? `Replaces the credential from “${heldByOther.label}” on this agent`
|
|
657
|
+
: providerOccupied
|
|
658
|
+
? 'Replaces the credential currently installed on this agent'
|
|
659
|
+
: undefined}
|
|
660
|
+
onClick={() => handleSyncOne(p, d.container, d.displayName || d.agentId)}
|
|
384
661
|
>
|
|
385
|
-
Sync
|
|
662
|
+
{busy === `sync:${p.id}:${d.container}` ? 'Syncing…' : 'Sync'}
|
|
386
663
|
</Button>
|
|
387
664
|
)}
|
|
388
665
|
</div>
|
|
@@ -403,6 +680,7 @@ export default function CredentialsPageClient() {
|
|
|
403
680
|
type="button"
|
|
404
681
|
variant={isConfigured ? 'ghost' : 'primary'}
|
|
405
682
|
size="sm"
|
|
683
|
+
disabled={!!busy}
|
|
406
684
|
onClick={() => setConfigureProviderId(provider.id)}
|
|
407
685
|
>
|
|
408
686
|
{isConfigured ? '+ Add another' : 'Configure'}
|
|
@@ -419,7 +697,7 @@ export default function CredentialsPageClient() {
|
|
|
419
697
|
providers={providers}
|
|
420
698
|
initialProviderId={configureProviderId ?? undefined}
|
|
421
699
|
onClose={() => setConfigureProviderId(null)}
|
|
422
|
-
onSaved={() => { setConfigureProviderId(null); showToast('Credential saved');
|
|
700
|
+
onSaved={async () => { await refreshAfterChange(); setConfigureProviderId(null); showToast('Credential saved'); }}
|
|
423
701
|
/>
|
|
424
702
|
|
|
425
703
|
{/* Edit Modal */}
|
|
@@ -429,18 +707,27 @@ export default function CredentialsPageClient() {
|
|
|
429
707
|
providers={providers}
|
|
430
708
|
editProfile={editProfile}
|
|
431
709
|
onClose={() => setEditProfile(null)}
|
|
432
|
-
onSaved={() => { setEditProfile(null); showToast('Credential updated');
|
|
710
|
+
onSaved={async () => { await refreshAfterChange(); setEditProfile(null); showToast('Credential updated'); }}
|
|
433
711
|
/>
|
|
434
712
|
)}
|
|
435
713
|
|
|
436
714
|
{/* Delete Confirm Modal */}
|
|
437
|
-
<Modal open={!!deleteId} onClose={() => setDeleteId(null)} title="Delete Credential" maxWidth={440}>
|
|
715
|
+
<Modal open={!!deleteId} onClose={() => { if (!busy) setDeleteId(null); }} title="Delete Credential" maxWidth={440}>
|
|
438
716
|
<p style={{ fontSize: 13, color: 'var(--text)', marginBottom: 20 }}>
|
|
439
717
|
This will permanently delete the credential and its secret. This cannot be undone.
|
|
440
718
|
</p>
|
|
441
719
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
442
|
-
<Button type="button" variant="secondary" size="sm" onClick={() => setDeleteId(null)}>Cancel</Button>
|
|
443
|
-
<Button
|
|
720
|
+
<Button type="button" variant="secondary" size="sm" disabled={!!busy} onClick={() => setDeleteId(null)}>Cancel</Button>
|
|
721
|
+
<Button
|
|
722
|
+
type="button"
|
|
723
|
+
variant="danger"
|
|
724
|
+
size="sm"
|
|
725
|
+
loading={busy === `delete:${deleteId}`}
|
|
726
|
+
disabled={!!busy}
|
|
727
|
+
onClick={() => { if (deleteId) void handleDelete(deleteId); }}
|
|
728
|
+
>
|
|
729
|
+
{busy === `delete:${deleteId}` ? 'Deleting…' : 'Delete'}
|
|
730
|
+
</Button>
|
|
444
731
|
</div>
|
|
445
732
|
</Modal>
|
|
446
733
|
|
|
@@ -463,7 +750,9 @@ interface AddEditModalProps {
|
|
|
463
750
|
initialProviderId?: string;
|
|
464
751
|
editProfile?: CredentialProfile | null;
|
|
465
752
|
onClose: () => void;
|
|
466
|
-
|
|
753
|
+
/** May be async: the modal keeps its Save button spinning until it settles,
|
|
754
|
+
* so the dialog closes once the new data is on screen, not before. */
|
|
755
|
+
onSaved: () => void | Promise<void>;
|
|
467
756
|
}
|
|
468
757
|
|
|
469
758
|
function AddEditModal({ open, providers, initialProviderId, editProfile, onClose, onSaved }: AddEditModalProps) {
|
|
@@ -471,6 +760,10 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
|
|
|
471
760
|
const [label, setLabel] = useState(editProfile?.label ?? '');
|
|
472
761
|
const [fields, setFields] = useState<FieldValues>({});
|
|
473
762
|
const [saving, setSaving] = useState(false);
|
|
763
|
+
// Rule 6a: `saving` is read from the render that produced the click, so two
|
|
764
|
+
// clicks landing before React commits the disabled button both see false and
|
|
765
|
+
// both POST — and `createProfile` mints a fresh id each time, so both persist.
|
|
766
|
+
const savingRef = useRef(false);
|
|
474
767
|
const [error, setError] = useState<string | null>(null);
|
|
475
768
|
|
|
476
769
|
const providerId = editProfile?.providerId ?? initialProviderId ?? providers[0]?.id ?? '';
|
|
@@ -494,6 +787,8 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
|
|
|
494
787
|
if (missingField) { setError(`${missingField.label} is required`); return; }
|
|
495
788
|
}
|
|
496
789
|
|
|
790
|
+
if (savingRef.current) return;
|
|
791
|
+
savingRef.current = true;
|
|
497
792
|
setSaving(true);
|
|
498
793
|
setError(null);
|
|
499
794
|
try {
|
|
@@ -503,21 +798,20 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
|
|
|
503
798
|
headers: { 'Content-Type': 'application/json' },
|
|
504
799
|
body: JSON.stringify({ label: label.trim(), secret: fields }),
|
|
505
800
|
});
|
|
506
|
-
|
|
507
|
-
if (json.error) throw new Error(json.error);
|
|
801
|
+
await readMutation<Record<string, never>>(res);
|
|
508
802
|
} else {
|
|
509
803
|
const res = await fetch('/api/credentials', {
|
|
510
804
|
method: 'POST',
|
|
511
805
|
headers: { 'Content-Type': 'application/json' },
|
|
512
806
|
body: JSON.stringify({ providerId, label: label.trim(), secret: fields }),
|
|
513
807
|
});
|
|
514
|
-
|
|
515
|
-
if (json.error) throw new Error(json.error);
|
|
808
|
+
await readMutation<{ id?: string }>(res);
|
|
516
809
|
}
|
|
517
|
-
onSaved();
|
|
810
|
+
await onSaved();
|
|
518
811
|
} catch (e: unknown) {
|
|
519
812
|
setError((e as Error).message);
|
|
520
813
|
} finally {
|
|
814
|
+
savingRef.current = false;
|
|
521
815
|
setSaving(false);
|
|
522
816
|
}
|
|
523
817
|
};
|
|
@@ -542,43 +836,70 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
|
|
|
542
836
|
placeholder={`e.g. Production ${provider?.label ?? ''}`}
|
|
543
837
|
/>
|
|
544
838
|
|
|
545
|
-
{provider?.fields.map((field) =>
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
style={{
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
839
|
+
{provider?.fields.map((field) => {
|
|
840
|
+
// Trello is the only provider whose second value is minted rather than
|
|
841
|
+
// copied: the token comes back from an authorize URL built out of the
|
|
842
|
+
// API key, so the control lives under the token field it fills, and
|
|
843
|
+
// stays visible-but-disabled until the key it needs is there. Hiding
|
|
844
|
+
// it until then made it appear out of nowhere, and left the token
|
|
845
|
+
// field's hint pointing at a button that was not on screen.
|
|
846
|
+
const isTrelloToken = provider.id === 'trello' && field.key === 'token';
|
|
847
|
+
const apiKeyReady = (fields['apiKey'] ?? '').trim().length > 0;
|
|
848
|
+
|
|
849
|
+
return (
|
|
850
|
+
<div key={field.key}>
|
|
851
|
+
<Input
|
|
852
|
+
label={field.label}
|
|
853
|
+
type={field.type}
|
|
854
|
+
value={fields[field.key] ?? ''}
|
|
855
|
+
onChange={(e) => setField(field.key, e.target.value)}
|
|
856
|
+
placeholder={isEdit ? '(leave empty to keep current)' : field.placeholder}
|
|
857
|
+
autoComplete="off"
|
|
858
|
+
/>
|
|
859
|
+
{(field.hint || field.docsUrl) && (
|
|
860
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 4 }}>
|
|
861
|
+
{field.hint}
|
|
862
|
+
{field.docsUrl && (
|
|
863
|
+
<a
|
|
864
|
+
href={field.docsUrl}
|
|
865
|
+
target="_blank"
|
|
866
|
+
rel="noopener noreferrer"
|
|
867
|
+
style={{ color: 'var(--violet)', marginLeft: field.hint ? 6 : 0 }}
|
|
868
|
+
>
|
|
869
|
+
{field.docsLabel ?? 'docs'} ↗
|
|
870
|
+
</a>
|
|
871
|
+
)}
|
|
872
|
+
</div>
|
|
873
|
+
)}
|
|
874
|
+
{isTrelloToken && (
|
|
875
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
|
|
876
|
+
<Button
|
|
877
|
+
type="button"
|
|
878
|
+
variant="primary"
|
|
879
|
+
size="sm"
|
|
880
|
+
disabled={!apiKeyReady}
|
|
881
|
+
onClick={() =>
|
|
882
|
+
window.open(
|
|
883
|
+
`https://trello.com/1/authorize?key=${encodeURIComponent(fields['apiKey'] ?? '')}&response_type=token&scope=read,write&expiration=never&name=Rev4a`,
|
|
884
|
+
'_blank',
|
|
885
|
+
'noopener,noreferrer',
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
>
|
|
889
|
+
Generate Trello Token ↗
|
|
890
|
+
</Button>
|
|
891
|
+
{!apiKeyReady && (
|
|
892
|
+
<span style={{ fontSize: 11, color: 'var(--text-dim)' }}>
|
|
893
|
+
{isEdit
|
|
894
|
+
? 'Re-enter the API Key to generate a new token'
|
|
895
|
+
: 'Fill in the API Key first'}
|
|
896
|
+
</span>
|
|
897
|
+
)}
|
|
898
|
+
</div>
|
|
899
|
+
)}
|
|
900
|
+
</div>
|
|
901
|
+
);
|
|
902
|
+
})}
|
|
582
903
|
|
|
583
904
|
{error && (
|
|
584
905
|
<div style={{ fontSize: 12, color: 'var(--red)', padding: '6px 10px', border: '1px solid var(--red)' }}>
|
|
@@ -587,9 +908,9 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
|
|
|
587
908
|
)}
|
|
588
909
|
|
|
589
910
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
590
|
-
<Button type="button" variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
|
|
911
|
+
<Button type="button" variant="secondary" size="sm" disabled={saving} onClick={onClose}>Cancel</Button>
|
|
591
912
|
<Button type="button" variant="primary" size="sm" loading={saving} onClick={() => void handleSave()}>
|
|
592
|
-
{isEdit ? 'Save Changes' : 'Save'}
|
|
913
|
+
{saving ? 'Saving…' : (isEdit ? 'Save Changes' : 'Save')}
|
|
593
914
|
</Button>
|
|
594
915
|
</div>
|
|
595
916
|
</div>
|