@zackbart/connecta 0.6.0 → 0.7.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/CHANGELOG.md +403 -0
- package/README.md +163 -308
- package/dist/auth/bearer.d.ts +4 -3
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +10 -8
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +8 -7
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +27 -8
- package/dist/auth/clerk.js.map +1 -1
- package/dist/connector-scope.d.ts +13 -0
- package/dist/connector-scope.d.ts.map +1 -0
- package/dist/connector-scope.js +35 -0
- package/dist/connector-scope.js.map +1 -0
- package/dist/connectors/api.d.ts +5 -5
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +3 -3
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +309 -10
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +20 -9
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +127 -63
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +84 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +109 -2
- package/dist/credentials.js.map +1 -1
- package/dist/index.d.ts +83 -82
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -31
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +3 -3
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +16 -7
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +3 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -3
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +154 -52
- package/dist/server.js.map +1 -1
- package/dist/skills.js +2 -2
- package/dist/skills.js.map +1 -1
- package/dist/toolkits.js +1 -1
- package/dist/types.d.ts +51 -26
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +52 -21
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +665 -196
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/src/auth/bearer.ts +10 -8
- package/src/auth/clerk.ts +28 -9
- package/src/connector-scope.ts +41 -0
- package/src/connectors/api.ts +5 -5
- package/src/connectors/remote-mcp.ts +348 -25
- package/src/credential-health.ts +151 -71
- package/src/credentials.ts +166 -3
- package/src/index.ts +202 -113
- package/src/meta-tools.ts +22 -7
- package/src/registry.ts +4 -3
- package/src/server.ts +197 -71
- package/src/skills.ts +2 -2
- package/src/toolkits.ts +1 -1
- package/src/types.ts +51 -26
- package/src/ui.ts +703 -195
- package/src/version.ts +1 -1
package/dist/ui.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "./credentials.js";
|
|
2
|
+
import { closeConnectorScope } from "./connector-scope.js";
|
|
1
3
|
/** Connecta's default monochrome "C" mark. */
|
|
2
4
|
export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
3
5
|
<style>
|
|
@@ -40,6 +42,19 @@ export function resolveBranding(branding) {
|
|
|
40
42
|
themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
|
|
41
43
|
};
|
|
42
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether the operator meant to supply a value here — the question every
|
|
47
|
+
* dropped-URL warning asks before naming a field, and one definition so the
|
|
48
|
+
* branding and `uiAuth` warnings cannot answer it differently. A non-string
|
|
49
|
+
* counts as set: the intent was there and is exactly what the warning reports
|
|
50
|
+
* on. A blank or whitespace-only string does not; that is indistinguishable
|
|
51
|
+
* from leaving the field alone, and both take the default silently.
|
|
52
|
+
*/
|
|
53
|
+
function isSetUrlValue(value) {
|
|
54
|
+
return typeof value === "string"
|
|
55
|
+
? trimmedString(value) !== undefined
|
|
56
|
+
: value !== undefined && value !== null;
|
|
57
|
+
}
|
|
43
58
|
/**
|
|
44
59
|
* Names of the branding URLs the operator set that failed their gate and were
|
|
45
60
|
* replaced by a default. Lives beside the gates so the startup warning cannot
|
|
@@ -50,16 +65,15 @@ export function droppedBrandingUrls(branding) {
|
|
|
50
65
|
if (!branding)
|
|
51
66
|
return [];
|
|
52
67
|
const resolved = resolveBranding(branding);
|
|
53
|
-
// A non-string still counts as "set": the operator meant to supply a URL, and
|
|
54
|
-
// that intent is exactly what the warning reports on. A blank string does not.
|
|
55
|
-
const isSet = (value) => typeof value === "string"
|
|
56
|
-
? trimmedString(value) !== undefined
|
|
57
|
-
: value !== undefined && value !== null;
|
|
58
68
|
const faviconHref = branding.favicon?.href;
|
|
59
69
|
return [
|
|
60
|
-
...(
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
...(isSetUrlValue(branding.productUrl) && !resolved.productUrl
|
|
71
|
+
? ["productUrl"]
|
|
72
|
+
: []),
|
|
73
|
+
...(isSetUrlValue(branding.ownerUrl) && !resolved.ownerUrl
|
|
74
|
+
? ["ownerUrl"]
|
|
75
|
+
: []),
|
|
76
|
+
...(isSetUrlValue(faviconHref) &&
|
|
63
77
|
trimmedString(faviconHref) !== resolved.faviconHref
|
|
64
78
|
? ["favicon.href"]
|
|
65
79
|
: []),
|
|
@@ -106,8 +120,8 @@ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
|
|
|
106
120
|
* default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
|
|
107
121
|
* reject — and it is kept narrow on both ends.
|
|
108
122
|
*
|
|
109
|
-
* Root-relative only, because
|
|
110
|
-
* depths and a document-relative path would resolve differently
|
|
123
|
+
* Root-relative only, because operator and OAuth callback pages sit at
|
|
124
|
+
* different depths and a document-relative path would resolve differently.
|
|
111
125
|
*
|
|
112
126
|
* "Root-relative" is enforced structurally: exactly one leading `/` followed by
|
|
113
127
|
* a character that is neither `/` nor `\`. Both of those would make the value an
|
|
@@ -133,20 +147,26 @@ export function isSafeIconHref(href) {
|
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
149
|
/**
|
|
136
|
-
* True only for an absolute `https:` URL — the gate
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* left to remember.
|
|
150
|
+
* True only for an absolute `https:` URL — the gate every `uiAuth` URL passes:
|
|
151
|
+
* `frontendApiUrl`, which becomes the operator shell's sign-in loader source,
|
|
152
|
+
* and `signInUrl`/`signUpUrl`, which ClerkJS uses as *navigation targets* when
|
|
153
|
+
* the operator signs in. With those three gated, no operator-config value
|
|
154
|
+
* reaches the browser in a URL position — attribute or navigation — without
|
|
155
|
+
* validation, and there is no exception left to remember.
|
|
142
156
|
*
|
|
143
|
-
* Stricter than `isSafeHttpUrl` on purpose
|
|
144
|
-
*
|
|
145
|
-
* derives it from the publishable key and Clerk's Frontend API is
|
|
146
|
-
*
|
|
147
|
-
* browser's mixed-content rules had not already blocked
|
|
157
|
+
* Stricter than `isSafeHttpUrl` on purpose: no `http:` carve-out, no loopback
|
|
158
|
+
* carve-out, and no relative form. Nobody types `frontendApiUrl` — the shipped
|
|
159
|
+
* Clerk adapter derives it from the publishable key, and Clerk's Frontend API is
|
|
160
|
+
* always https — and a cleartext script source on an operator page would be a
|
|
161
|
+
* downgrade even where a browser's mixed-content rules had not already blocked
|
|
162
|
+
* it. `signInUrl`/`signUpUrl` *are* typed by the operator, but what belongs
|
|
163
|
+
* there is a hosted Account Portal address (`https://accounts.<domain>` or
|
|
164
|
+
* `https://<slug>.accounts.dev`), which is https as well; `http:` would carry a
|
|
165
|
+
* sign-in over cleartext, and a path relative to this origin is meaningless
|
|
166
|
+
* because this server hosts no sign-in page of its own. So the looser gate would
|
|
167
|
+
* buy nothing real, and the same strictness holds for all three.
|
|
148
168
|
*/
|
|
149
|
-
export function
|
|
169
|
+
export function isSafeHttpsUrl(url) {
|
|
150
170
|
if (typeof url !== "string")
|
|
151
171
|
return false;
|
|
152
172
|
try {
|
|
@@ -161,15 +181,58 @@ export function isSafeScriptSrcUrl(url) {
|
|
|
161
181
|
* gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
|
|
162
182
|
* startup warning cannot then drift from what rendering actually drops. Every
|
|
163
183
|
* field is read defensively rather than trusted, because a custom `InboundAuth`
|
|
164
|
-
* is untyped at a JS call site — `
|
|
184
|
+
* is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
|
|
165
185
|
* `uiAuth` that is not the clerk shape is reported as nothing to warn about.
|
|
186
|
+
*
|
|
187
|
+
* `frontendApiUrl` is required, so anything that fails its gate is a drop.
|
|
188
|
+
* `signInUrl` and `signUpUrl` are optional, so only a value the operator
|
|
189
|
+
* *supplied* and the gate then rejected is worth a warning — an unset field
|
|
190
|
+
* took no default away from anyone. `isSetUrlValue` decides that, the same way
|
|
191
|
+
* and for the same reasons it decides it for the branding URLs: a warning that
|
|
192
|
+
* fires for one and not the other would be reporting on the field rather than
|
|
193
|
+
* on the operator's intent. Rendering is not consulted for this: it drops on
|
|
194
|
+
* the gate alone, and a blank string fails that gate too — it is simply not
|
|
195
|
+
* *reported*, because a blank is indistinguishable from leaving the field
|
|
196
|
+
* alone.
|
|
166
197
|
*/
|
|
167
198
|
export function droppedUiAuthUrls(uiAuth) {
|
|
168
199
|
if (!uiAuth || uiAuth.kind !== "clerk")
|
|
169
200
|
return [];
|
|
170
|
-
return
|
|
171
|
-
? []
|
|
172
|
-
|
|
201
|
+
return [
|
|
202
|
+
...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
|
|
203
|
+
...(isSetUrlValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
|
|
204
|
+
? ["uiAuth.signInUrl"]
|
|
205
|
+
: []),
|
|
206
|
+
...(isSetUrlValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
|
|
207
|
+
? ["uiAuth.signUpUrl"]
|
|
208
|
+
: []),
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
const OPERATOR_PAGE_LABELS = {
|
|
212
|
+
connections: "Connections",
|
|
213
|
+
credentials: "Credentials",
|
|
214
|
+
activity: "Activity",
|
|
215
|
+
};
|
|
216
|
+
export function operatorPageForPath(path) {
|
|
217
|
+
if (path === "/")
|
|
218
|
+
return "connections";
|
|
219
|
+
if (path === "/credentials")
|
|
220
|
+
return "credentials";
|
|
221
|
+
if (path === "/activity")
|
|
222
|
+
return "activity";
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
export function operatorPageTitle(page, configuredTitle) {
|
|
226
|
+
return `${OPERATOR_PAGE_LABELS[page]} — ${configuredTitle}`;
|
|
227
|
+
}
|
|
228
|
+
export function credentialManagementCapability(input) {
|
|
229
|
+
if (!input.eligibleClerkOperator)
|
|
230
|
+
return "requires_clerk";
|
|
231
|
+
if (!input.hasCredentialSlots)
|
|
232
|
+
return "no_slots";
|
|
233
|
+
if (!input.hasCredentialVault)
|
|
234
|
+
return "vault_not_configured";
|
|
235
|
+
return "available";
|
|
173
236
|
}
|
|
174
237
|
/**
|
|
175
238
|
* Filter by connector identity/description or tool name/description. A
|
|
@@ -203,9 +266,12 @@ export function filterUiConnectors(connectors, query) {
|
|
|
203
266
|
* isolated: they surface status "error" with an empty tool list rather than
|
|
204
267
|
* failing the whole payload.
|
|
205
268
|
*/
|
|
206
|
-
export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false
|
|
269
|
+
export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false, credentialManagement = credentialVault
|
|
270
|
+
? "available"
|
|
271
|
+
: "requires_clerk") {
|
|
207
272
|
const requestScope = {};
|
|
208
|
-
const
|
|
273
|
+
const connectorSet = registry.listConnectors();
|
|
274
|
+
const connectors = await Promise.all(connectorSet.map(async (c) => {
|
|
209
275
|
const status = await registry.statusFor(c.id, baseUrl, requestScope);
|
|
210
276
|
const credentialCheck = await registry.credentialHealthFor(c.id);
|
|
211
277
|
let tools = [];
|
|
@@ -228,6 +294,10 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
|
|
|
228
294
|
}
|
|
229
295
|
let credential;
|
|
230
296
|
if (c.credential && credentialVault) {
|
|
297
|
+
// One rule, shared with the test route: only the hook matching the
|
|
298
|
+
// declared credential shape can run, so the button is offered only
|
|
299
|
+
// where a click can succeed (src/credentials.ts).
|
|
300
|
+
const testRule = credentialTestRule(c);
|
|
231
301
|
const credentialFields = (metadata) => c.credential?.fields?.map((field) => {
|
|
232
302
|
const fieldMetadata = metadata?.fields?.[field.name];
|
|
233
303
|
return {
|
|
@@ -252,6 +322,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
|
|
|
252
322
|
try {
|
|
253
323
|
const metadata = await credentialVault.metadata(c.id);
|
|
254
324
|
const fields = credentialFields(metadata);
|
|
325
|
+
const shape = storedCredentialShape(c.credential, metadata?.fields ?? null);
|
|
255
326
|
credential = {
|
|
256
327
|
label: c.credential.label,
|
|
257
328
|
...(c.credential.description
|
|
@@ -261,9 +332,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
|
|
|
261
332
|
? { placeholder: c.credential.placeholder }
|
|
262
333
|
: {}),
|
|
263
334
|
...(fields?.length ? { fields } : {}),
|
|
264
|
-
configured:
|
|
265
|
-
? fields.every((field) => field.configured)
|
|
266
|
-
: Boolean(metadata),
|
|
335
|
+
configured: shape.state === "valid",
|
|
267
336
|
removable: Boolean(metadata),
|
|
268
337
|
...(metadata
|
|
269
338
|
? {
|
|
@@ -271,7 +340,18 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
|
|
|
271
340
|
updatedAt: metadata.updatedAt,
|
|
272
341
|
}
|
|
273
342
|
: {}),
|
|
274
|
-
testable:
|
|
343
|
+
testable: testRule.mode !== null && shape.state !== "mismatch",
|
|
344
|
+
...(shape.state === "mismatch"
|
|
345
|
+
? { error: shape.message }
|
|
346
|
+
: {}),
|
|
347
|
+
// A dropped field leaves its secret in the vault, and the field
|
|
348
|
+
// list below only renders fields the connector still declares —
|
|
349
|
+
// so without this line there is nowhere an operator could see it.
|
|
350
|
+
...(shape.state === "valid" && shape.undeclared.length
|
|
351
|
+
? {
|
|
352
|
+
notice: describeUndeclaredCredentialFields(shape.undeclared),
|
|
353
|
+
}
|
|
354
|
+
: {}),
|
|
275
355
|
};
|
|
276
356
|
}
|
|
277
357
|
catch {
|
|
@@ -287,7 +367,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
|
|
|
287
367
|
...(fields?.length ? { fields } : {}),
|
|
288
368
|
configured: false,
|
|
289
369
|
removable: true,
|
|
290
|
-
testable:
|
|
370
|
+
testable: testRule.mode !== null,
|
|
291
371
|
error: "Stored credential could not be read.",
|
|
292
372
|
};
|
|
293
373
|
}
|
|
@@ -316,8 +396,15 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
|
|
|
316
396
|
: {}),
|
|
317
397
|
...(credential ? { credential } : {}),
|
|
318
398
|
};
|
|
319
|
-
}))
|
|
320
|
-
|
|
399
|
+
})).finally(async () => {
|
|
400
|
+
await Promise.all(connectorSet.map((connector) => closeConnectorScope(connector, registry.contextFor(connector.id, baseUrl, requestScope))));
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
serverInfo,
|
|
404
|
+
connectors,
|
|
405
|
+
activityEnabled,
|
|
406
|
+
credentialManagement,
|
|
407
|
+
};
|
|
321
408
|
}
|
|
322
409
|
function escapeHtmlAttr(value) {
|
|
323
410
|
return value
|
|
@@ -333,36 +420,41 @@ function jsonForInlineScript(value) {
|
|
|
333
420
|
.replaceAll("&", "\\u0026");
|
|
334
421
|
}
|
|
335
422
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* normal sign-in flow and a short-lived session token; bearer-only deployments
|
|
339
|
-
* retain the manual-token fallback.
|
|
423
|
+
* Every operator page serves this same data-free shell. Connector, credential,
|
|
424
|
+
* and activity data arrives only through the authenticated `/ui/*` APIs.
|
|
340
425
|
*/
|
|
341
|
-
export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce) {
|
|
426
|
+
export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "connections") {
|
|
342
427
|
const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
|
|
343
428
|
// The Clerk loader's origin. A value that fails the gate is dropped rather
|
|
344
429
|
// than escaped into the page: the loader tag is simply not emitted, the gate
|
|
345
430
|
// reports that Clerk could not load, and the rest of the shell still renders —
|
|
346
431
|
// the same fallback-and-warn posture the branding URLs take, with the drop
|
|
347
432
|
// named in a startup warning (see `droppedUiAuthUrls`).
|
|
348
|
-
const clerkScriptOrigin = clerk &&
|
|
433
|
+
const clerkScriptOrigin = clerk && isSafeHttpsUrl(clerk.frontendApiUrl)
|
|
349
434
|
? clerk.frontendApiUrl
|
|
350
435
|
: undefined;
|
|
351
436
|
// Enumerated field by field, because this object is serialized into the page's
|
|
352
437
|
// inline script: a rejected frontendApiUrl must not reach the document through
|
|
353
|
-
// `AUTH` after being kept out of the `<script src
|
|
438
|
+
// `AUTH` after being kept out of the `<script src>`, and a rejected
|
|
439
|
+
// signInUrl/signUpUrl — which `AUTH` is the only path into the page for — must
|
|
440
|
+
// not reach it at all. Dropping one leaves the key absent, so `Clerk.load`
|
|
441
|
+
// falls back to its own default the same way it does for an unset value.
|
|
354
442
|
const auth = clerk
|
|
355
443
|
? {
|
|
356
444
|
kind: clerk.kind,
|
|
357
445
|
publishableKey: clerk.publishableKey,
|
|
358
446
|
...(clerkScriptOrigin ? { frontendApiUrl: clerkScriptOrigin } : {}),
|
|
359
|
-
...(clerk.signInUrl
|
|
360
|
-
|
|
447
|
+
...(isSafeHttpsUrl(clerk.signInUrl)
|
|
448
|
+
? { signInUrl: clerk.signInUrl }
|
|
449
|
+
: {}),
|
|
450
|
+
...(isSafeHttpsUrl(clerk.signUpUrl)
|
|
451
|
+
? { signUpUrl: clerk.signUpUrl }
|
|
452
|
+
: {}),
|
|
361
453
|
}
|
|
362
454
|
: (uiAuth ?? { kind: "bearer" });
|
|
363
455
|
const brand = resolveBranding(branding);
|
|
364
|
-
const title = brand.pageTitle;
|
|
365
|
-
// When
|
|
456
|
+
const title = operatorPageTitle(page, brand.pageTitle);
|
|
457
|
+
// When an operator shell ships a nonce-based CSP, every script it emits must
|
|
366
458
|
// carry that nonce to run; without a nonce the markup is unchanged.
|
|
367
459
|
const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
|
|
368
460
|
// Top-left corner. With an owner set it reads "<owner> <product>"; without
|
|
@@ -401,6 +493,7 @@ ${clerkScript}
|
|
|
401
493
|
--paper: #fff;
|
|
402
494
|
--rule: #ccc;
|
|
403
495
|
--muted: #666;
|
|
496
|
+
--trace: #f5f5f5;
|
|
404
497
|
--shell: 70rem;
|
|
405
498
|
--pad: 1rem;
|
|
406
499
|
--gap: 1.5rem;
|
|
@@ -447,6 +540,15 @@ ${clerkScript}
|
|
|
447
540
|
outline: 1px solid var(--ink);
|
|
448
541
|
}
|
|
449
542
|
:is(a, button, summary):focus-visible { outline-offset: 2px; }
|
|
543
|
+
.skip-link {
|
|
544
|
+
background: var(--paper);
|
|
545
|
+
left: var(--pad);
|
|
546
|
+
padding: .5rem;
|
|
547
|
+
position: fixed;
|
|
548
|
+
top: -4rem;
|
|
549
|
+
z-index: 10;
|
|
550
|
+
}
|
|
551
|
+
.skip-link:focus { top: var(--pad); }
|
|
450
552
|
|
|
451
553
|
.shell {
|
|
452
554
|
margin: 0 auto;
|
|
@@ -465,6 +567,15 @@ ${clerkScript}
|
|
|
465
567
|
.cap, .meta { color: var(--muted); font-size: .9em; }
|
|
466
568
|
.mono { font-family: var(--mono); font-size: .78rem; }
|
|
467
569
|
.hidden { display: none !important; }
|
|
570
|
+
.visually-hidden {
|
|
571
|
+
clip: rect(0 0 0 0);
|
|
572
|
+
clip-path: inset(50%);
|
|
573
|
+
height: 1px;
|
|
574
|
+
overflow: hidden;
|
|
575
|
+
position: absolute;
|
|
576
|
+
white-space: nowrap;
|
|
577
|
+
width: 1px;
|
|
578
|
+
}
|
|
468
579
|
|
|
469
580
|
.masthead {
|
|
470
581
|
align-items: start;
|
|
@@ -489,6 +600,17 @@ ${clerkScript}
|
|
|
489
600
|
justify-content: flex-end;
|
|
490
601
|
min-width: 0;
|
|
491
602
|
}
|
|
603
|
+
.page-nav,
|
|
604
|
+
.session-actions {
|
|
605
|
+
display: flex;
|
|
606
|
+
flex-wrap: wrap;
|
|
607
|
+
gap: .5rem var(--gap);
|
|
608
|
+
}
|
|
609
|
+
.mast-actions :is(a, button) {
|
|
610
|
+
align-items: center;
|
|
611
|
+
display: inline-flex;
|
|
612
|
+
min-height: 2rem;
|
|
613
|
+
}
|
|
492
614
|
.navlink,
|
|
493
615
|
.linklike {
|
|
494
616
|
text-decoration: underline;
|
|
@@ -498,7 +620,7 @@ ${clerkScript}
|
|
|
498
620
|
.navlink { text-decoration-color: transparent; }
|
|
499
621
|
.navlink:hover,
|
|
500
622
|
.navlink:focus-visible,
|
|
501
|
-
.navlink
|
|
623
|
+
.navlink[aria-current="page"] { text-decoration-color: currentColor; }
|
|
502
624
|
.linklike { text-decoration-color: currentColor; }
|
|
503
625
|
.linklike:hover,
|
|
504
626
|
.linklike:focus-visible { text-decoration-color: transparent; }
|
|
@@ -546,7 +668,24 @@ ${clerkScript}
|
|
|
546
668
|
#notice:empty { display: none; }
|
|
547
669
|
#notice:not(:empty) { text-decoration: underline; }
|
|
548
670
|
.error-notice, .msg { text-decoration: underline; }
|
|
549
|
-
.card
|
|
671
|
+
.card,
|
|
672
|
+
.credential-card,
|
|
673
|
+
.activity-item {
|
|
674
|
+
padding-left: 1.25rem;
|
|
675
|
+
position: relative;
|
|
676
|
+
}
|
|
677
|
+
.card::before,
|
|
678
|
+
.credential-card::before,
|
|
679
|
+
.activity-item::before {
|
|
680
|
+
background: var(--rule);
|
|
681
|
+
bottom: 0;
|
|
682
|
+
content: "";
|
|
683
|
+
left: .25rem;
|
|
684
|
+
position: absolute;
|
|
685
|
+
top: 0;
|
|
686
|
+
width: 1px;
|
|
687
|
+
}
|
|
688
|
+
.card { border-top: 1px solid var(--rule); padding-bottom: .75rem; padding-top: .75rem; }
|
|
550
689
|
.connector-head {
|
|
551
690
|
display: grid;
|
|
552
691
|
gap: var(--gap);
|
|
@@ -557,14 +696,25 @@ ${clerkScript}
|
|
|
557
696
|
display: flex;
|
|
558
697
|
gap: .5rem;
|
|
559
698
|
}
|
|
699
|
+
.connector-title .dot,
|
|
700
|
+
.activity-stamp .dot {
|
|
701
|
+
margin-left: -1.25rem;
|
|
702
|
+
}
|
|
703
|
+
.activity-stamp {
|
|
704
|
+
align-items: baseline;
|
|
705
|
+
display: flex;
|
|
706
|
+
gap: .75rem;
|
|
707
|
+
}
|
|
560
708
|
.card h2 { overflow-wrap: anywhere; }
|
|
561
709
|
.connector-state { text-align: right; }
|
|
562
710
|
.dot {
|
|
711
|
+
background: var(--paper);
|
|
563
712
|
border: 1px solid var(--ink);
|
|
564
713
|
display: inline-block;
|
|
565
714
|
flex: none;
|
|
566
715
|
height: .5rem;
|
|
567
716
|
width: .5rem;
|
|
717
|
+
z-index: 1;
|
|
568
718
|
}
|
|
569
719
|
.dot.ok { background: var(--ink); }
|
|
570
720
|
.dot.auth_required {
|
|
@@ -574,7 +724,8 @@ ${clerkScript}
|
|
|
574
724
|
.connector-message,
|
|
575
725
|
.connector-auth { margin-top: .75rem; }
|
|
576
726
|
|
|
577
|
-
.credential { border-
|
|
727
|
+
.credential-ledger { border-bottom: 1px solid var(--rule); }
|
|
728
|
+
.credential-card { border-top: 1px solid var(--rule); padding-bottom: .75rem; padding-top: .75rem; }
|
|
578
729
|
.credential-head {
|
|
579
730
|
align-items: baseline;
|
|
580
731
|
display: flex;
|
|
@@ -583,7 +734,27 @@ ${clerkScript}
|
|
|
583
734
|
justify-content: space-between;
|
|
584
735
|
}
|
|
585
736
|
.credential-copy { margin-top: .25rem; max-width: 40rem; }
|
|
737
|
+
.credential-field-summary {
|
|
738
|
+
border-top: 1px solid var(--rule);
|
|
739
|
+
margin-top: .75rem;
|
|
740
|
+
}
|
|
741
|
+
.credential-field-summary > div {
|
|
742
|
+
border-bottom: 1px solid var(--rule);
|
|
743
|
+
display: flex;
|
|
744
|
+
flex-wrap: wrap;
|
|
745
|
+
gap: .25rem var(--gap);
|
|
746
|
+
justify-content: space-between;
|
|
747
|
+
padding: .5rem 0;
|
|
748
|
+
}
|
|
586
749
|
.credential-actions { display: flex; flex-wrap: wrap; gap: var(--gap); margin-top: .75rem; }
|
|
750
|
+
.credential-actions button,
|
|
751
|
+
.credential-form button,
|
|
752
|
+
.activity-controls button,
|
|
753
|
+
.activity-more {
|
|
754
|
+
align-items: center;
|
|
755
|
+
display: inline-flex;
|
|
756
|
+
min-height: 2.75rem;
|
|
757
|
+
}
|
|
587
758
|
.credential-form {
|
|
588
759
|
align-items: center;
|
|
589
760
|
display: flex;
|
|
@@ -627,7 +798,8 @@ ${clerkScript}
|
|
|
627
798
|
display: grid;
|
|
628
799
|
gap: .25rem var(--gap);
|
|
629
800
|
grid-template-columns: minmax(9rem, .85fr) minmax(12rem, 1.4fr) minmax(8rem, .9fr);
|
|
630
|
-
padding: .75rem
|
|
801
|
+
padding-bottom: .75rem;
|
|
802
|
+
padding-top: .75rem;
|
|
631
803
|
}
|
|
632
804
|
.activity-time,
|
|
633
805
|
.activity-actor,
|
|
@@ -638,6 +810,16 @@ ${clerkScript}
|
|
|
638
810
|
.activity-item.timeout .activity-outcome { text-decoration: underline; }
|
|
639
811
|
.activity-empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
|
|
640
812
|
.activity-more { margin-top: .75rem; }
|
|
813
|
+
.unavailable {
|
|
814
|
+
background: var(--trace);
|
|
815
|
+
border-bottom: 1px solid var(--rule);
|
|
816
|
+
border-top: 1px solid var(--rule);
|
|
817
|
+
padding: .75rem;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
@media (prefers-reduced-motion: reduce) {
|
|
821
|
+
html:focus-within { scroll-behavior: auto; }
|
|
822
|
+
}
|
|
641
823
|
|
|
642
824
|
@media (max-width: 36.99rem) {
|
|
643
825
|
.pgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
@@ -646,7 +828,12 @@ ${clerkScript}
|
|
|
646
828
|
.masthead .brand { grid-column: 1; }
|
|
647
829
|
.mast-nav { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
|
|
648
830
|
.product { display: none; }
|
|
649
|
-
.mast-actions {
|
|
831
|
+
.mast-actions {
|
|
832
|
+
align-items: flex-start;
|
|
833
|
+
flex-direction: column;
|
|
834
|
+
font-size: .875rem;
|
|
835
|
+
gap: .25rem;
|
|
836
|
+
}
|
|
650
837
|
.lead { margin-top: 4rem; }
|
|
651
838
|
.section,
|
|
652
839
|
.section + .section { margin-top: 2.5rem; }
|
|
@@ -655,49 +842,58 @@ ${clerkScript}
|
|
|
655
842
|
.activity-item { grid-template-columns: 1fr; }
|
|
656
843
|
.connector-state { text-align: left; }
|
|
657
844
|
.credential-field { align-items: start; grid-template-columns: 1fr; gap: .25rem; }
|
|
845
|
+
input { min-height: 2.75rem; }
|
|
658
846
|
}
|
|
659
847
|
</style>
|
|
660
848
|
</head>
|
|
661
849
|
<body>
|
|
850
|
+
<a class="skip-link" href="#operatorContent">Skip to operator page</a>
|
|
662
851
|
<header class="masthead shell pgrid">
|
|
663
852
|
${owner}
|
|
664
853
|
<div class="mast-nav">
|
|
665
854
|
${product}
|
|
666
|
-
<
|
|
667
|
-
<
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
855
|
+
<div id="appNav" class="mast-actions hidden">
|
|
856
|
+
<nav class="page-nav" aria-label="Operator pages">
|
|
857
|
+
<a id="connectionsNav" class="navlink" href="/"
|
|
858
|
+
data-operator-page="connections"${page === "connections" ? ' aria-current="page"' : ""}>Connections</a>
|
|
859
|
+
<a id="credentialsNav" class="navlink hidden" href="/credentials"
|
|
860
|
+
data-operator-page="credentials"${page === "credentials" ? ' aria-current="page"' : ""}>Credentials</a>
|
|
861
|
+
<a id="activityNav" class="navlink hidden" href="/activity"
|
|
862
|
+
data-operator-page="activity"${page === "activity" ? ' aria-current="page"' : ""}>Activity</a>
|
|
863
|
+
</nav>
|
|
864
|
+
<div class="session-actions" aria-label="Session actions">
|
|
865
|
+
<button id="change" class="navlink hidden" type="button">Change token</button>
|
|
866
|
+
<button id="signout" class="navlink hidden" type="button">Sign out</button>
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
674
869
|
</div>
|
|
675
870
|
</header>
|
|
676
871
|
|
|
677
|
-
<main id="
|
|
678
|
-
<section
|
|
679
|
-
<
|
|
680
|
-
|
|
681
|
-
<
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
<
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
<
|
|
690
|
-
|
|
872
|
+
<main id="operatorContent" class="page shell" tabindex="-1">
|
|
873
|
+
<section id="gate" class="hidden">
|
|
874
|
+
<div class="lead pgrid">
|
|
875
|
+
<h1 id="gateHeading" class="pcap" tabindex="-1">${OPERATOR_PAGE_LABELS[page]}</h1>
|
|
876
|
+
<div class="pbody lead-copy">
|
|
877
|
+
<p>${escapeHtmlAttr(brand.description)}</p>
|
|
878
|
+
<p id="gateCopy" class="meta"></p>
|
|
879
|
+
<div id="tokenGate" class="row gate-actions hidden">
|
|
880
|
+
<input id="token" type="password" placeholder="Bearer token" autocomplete="off"
|
|
881
|
+
aria-label="Bearer token">
|
|
882
|
+
<button id="save" class="linklike" type="button">Open operator pages</button>
|
|
883
|
+
</div>
|
|
884
|
+
<div id="clerkGate" class="actions gate-actions hidden">
|
|
885
|
+
<button id="signin" class="linklike" type="button">Team sign in</button>
|
|
886
|
+
<button id="gateSignout" class="linklike hidden" type="button">Sign out</button>
|
|
887
|
+
</div>
|
|
888
|
+
<p id="err" role="alert"></p>
|
|
691
889
|
</div>
|
|
692
|
-
<p id="err"></p>
|
|
693
890
|
</div>
|
|
694
891
|
</section>
|
|
695
|
-
</main>
|
|
696
892
|
|
|
697
|
-
<
|
|
698
|
-
<section id="
|
|
893
|
+
<div id="app" class="hidden">
|
|
894
|
+
<section id="connectionsView"${page === "connections" ? "" : ' class="hidden"'}>
|
|
699
895
|
<div class="lead pgrid">
|
|
700
|
-
<h1 class="pcap">
|
|
896
|
+
<h1 id="connectionsHeading" class="pcap" tabindex="-1">Connections</h1>
|
|
701
897
|
<div class="pbody lead-copy">
|
|
702
898
|
<p>Use this endpoint to give an MCP client access to the tools below.</p>
|
|
703
899
|
<div class="endpoint">
|
|
@@ -706,50 +902,80 @@ ${clerkScript}
|
|
|
706
902
|
<button id="copyMcpUrl" class="linklike" type="button">Copy URL</button>
|
|
707
903
|
</div>
|
|
708
904
|
</div>
|
|
709
|
-
<p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)}
|
|
905
|
+
<p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} operator</p>
|
|
710
906
|
</div>
|
|
711
907
|
</div>
|
|
712
|
-
<section class="section pgrid" aria-labelledby="
|
|
713
|
-
<h2 class="pcap" id="
|
|
908
|
+
<section class="section pgrid" aria-labelledby="connectorLedgerHeading">
|
|
909
|
+
<h2 class="pcap" id="connectorLedgerHeading">Connectors</h2>
|
|
714
910
|
<div class="pbody">
|
|
715
911
|
<div class="row toolbar">
|
|
716
912
|
<input id="filter" type="search" placeholder="Filter connectors or tools…"
|
|
717
913
|
aria-label="Filter connectors or tools">
|
|
718
914
|
</div>
|
|
719
|
-
<
|
|
720
|
-
<div id="list" class="connector-tools"></div>
|
|
915
|
+
<div id="list" class="connector-tools" aria-busy="false"></div>
|
|
721
916
|
</div>
|
|
722
917
|
</section>
|
|
723
918
|
</section>
|
|
724
|
-
|
|
919
|
+
|
|
920
|
+
<section id="credentialsView"${page === "credentials" ? "" : ' class="hidden"'}>
|
|
725
921
|
<div class="lead pgrid">
|
|
726
|
-
<h1 class="pcap">
|
|
922
|
+
<h1 id="credentialsHeading" class="pcap" tabindex="-1">Credentials</h1>
|
|
923
|
+
<div class="pbody">
|
|
924
|
+
<p class="activity-copy">Rotate operator-managed connector credentials. Stored values are never returned or displayed.</p>
|
|
925
|
+
<p id="credentialNotice" class="meta" role="status" aria-live="polite"
|
|
926
|
+
tabindex="-1"></p>
|
|
927
|
+
<div id="credentialUnavailable" class="unavailable hidden"></div>
|
|
928
|
+
<div id="credentialList" class="credential-ledger" aria-busy="false"></div>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
</section>
|
|
932
|
+
|
|
933
|
+
<section id="activityView"${page === "activity" ? "" : ' class="hidden"'}>
|
|
934
|
+
<div class="lead pgrid">
|
|
935
|
+
<h1 id="activityHeading" class="pcap" tabindex="-1">Activity</h1>
|
|
727
936
|
<div class="pbody">
|
|
728
937
|
<p class="activity-copy" id="activitySummary">Arguments and results are never stored.</p>
|
|
729
|
-
<div class="
|
|
730
|
-
<
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
938
|
+
<div id="activityUnavailable" class="unavailable hidden">
|
|
939
|
+
Activity history is not configured. Add an <span class="mono">activity.store</span>
|
|
940
|
+
with a list reader to enable this page.
|
|
941
|
+
</div>
|
|
942
|
+
<div id="activityAvailable">
|
|
943
|
+
<div class="row activity-controls">
|
|
944
|
+
<input id="activitySearch" type="search"
|
|
945
|
+
placeholder="Search user, tool, or outcome…"
|
|
946
|
+
aria-label="Search loaded activity">
|
|
947
|
+
<button id="refreshActivity" class="linklike" type="button">Refresh</button>
|
|
948
|
+
</div>
|
|
949
|
+
<p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
|
|
950
|
+
<div id="activityList" class="activity-ledger" aria-busy="false"></div>
|
|
951
|
+
<button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
|
|
734
952
|
</div>
|
|
735
|
-
<p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
|
|
736
|
-
<div id="activityList" class="activity-ledger"></div>
|
|
737
|
-
<button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
|
|
738
953
|
</div>
|
|
739
954
|
</div>
|
|
740
955
|
</section>
|
|
956
|
+
</div>
|
|
741
957
|
</main>
|
|
742
958
|
|
|
743
959
|
<script${nonceAttr}>
|
|
744
960
|
const AUTH = ${jsonForInlineScript(auth)};
|
|
745
961
|
const MCP_URL = ${jsonForInlineScript(mcpUrl)};
|
|
962
|
+
const INITIAL_PAGE = ${jsonForInlineScript(page)};
|
|
963
|
+
const TITLE_SUFFIX = ${jsonForInlineScript(brand.pageTitle)};
|
|
746
964
|
const filterUiConnectors = ${filterUiConnectors.toString()};
|
|
747
965
|
const KEY = "connecta:token";
|
|
748
966
|
const $ = (id) => document.getElementById(id);
|
|
967
|
+
const PAGE_META = {
|
|
968
|
+
connections: { path: "/", label: "Connections" },
|
|
969
|
+
credentials: { path: "/credentials", label: "Credentials" },
|
|
970
|
+
activity: { path: "/activity", label: "Activity" },
|
|
971
|
+
};
|
|
749
972
|
let DATA = null;
|
|
750
973
|
let ACTIVITY = [];
|
|
751
974
|
let ACTIVITY_CURSOR = null;
|
|
752
975
|
let ACTIVITY_LOADED = false;
|
|
976
|
+
let CURRENT_PAGE = INITIAL_PAGE;
|
|
977
|
+
let SESSION_GENERATION = 0;
|
|
978
|
+
let ACTIVITY_GENERATION = 0;
|
|
753
979
|
$("mcpUrl").textContent = MCP_URL;
|
|
754
980
|
|
|
755
981
|
function esc(s) {
|
|
@@ -773,8 +999,9 @@ function formatDate(value) {
|
|
|
773
999
|
}
|
|
774
1000
|
|
|
775
1001
|
function setNotice(message, isError) {
|
|
776
|
-
$("
|
|
777
|
-
$("
|
|
1002
|
+
$("credentialNotice").textContent = message || "";
|
|
1003
|
+
$("credentialNotice").classList.toggle("error-notice", Boolean(isError));
|
|
1004
|
+
$("credentialNotice").setAttribute("role", isError ? "alert" : "status");
|
|
778
1005
|
}
|
|
779
1006
|
|
|
780
1007
|
async function sessionToken() {
|
|
@@ -783,7 +1010,45 @@ async function sessionToken() {
|
|
|
783
1010
|
: localStorage.getItem(KEY);
|
|
784
1011
|
}
|
|
785
1012
|
|
|
1013
|
+
function clearActivityState() {
|
|
1014
|
+
ACTIVITY_GENERATION += 1;
|
|
1015
|
+
ACTIVITY = [];
|
|
1016
|
+
ACTIVITY_CURSOR = null;
|
|
1017
|
+
ACTIVITY_LOADED = false;
|
|
1018
|
+
$("activityList").innerHTML = "";
|
|
1019
|
+
$("activityList").setAttribute("aria-busy", "false");
|
|
1020
|
+
$("activityNotice").textContent = "";
|
|
1021
|
+
$("activityNotice").setAttribute("role", "status");
|
|
1022
|
+
$("activitySummary").textContent = "Arguments and results are never stored.";
|
|
1023
|
+
$("activitySearch").value = "";
|
|
1024
|
+
$("refreshActivity").disabled = false;
|
|
1025
|
+
$("moreActivity").disabled = false;
|
|
1026
|
+
$("moreActivity").classList.add("hidden");
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function clearIdentityState() {
|
|
1030
|
+
SESSION_GENERATION += 1;
|
|
1031
|
+
DATA = null;
|
|
1032
|
+
clearActivityState();
|
|
1033
|
+
$("list").innerHTML = "";
|
|
1034
|
+
$("filter").value = "";
|
|
1035
|
+
$("credentialList").innerHTML = "";
|
|
1036
|
+
$("credentialList").setAttribute("aria-busy", "false");
|
|
1037
|
+
$("credentialNotice").textContent = "";
|
|
1038
|
+
$("credentialNotice").setAttribute("role", "status");
|
|
1039
|
+
$("credentialNotice").classList.remove("error-notice");
|
|
1040
|
+
$("credentialUnavailable").textContent = "";
|
|
1041
|
+
$("credentialUnavailable").classList.add("hidden");
|
|
1042
|
+
$("credentialList").classList.add("hidden");
|
|
1043
|
+
$("activityUnavailable").classList.add("hidden");
|
|
1044
|
+
$("activityAvailable").classList.add("hidden");
|
|
1045
|
+
$("credentialsNav").classList.add("hidden");
|
|
1046
|
+
$("activityNav").classList.add("hidden");
|
|
1047
|
+
$("serverInfo").textContent = ${escapeScriptString(brand.productName + " operator")};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
786
1050
|
function showGate(msg) {
|
|
1051
|
+
clearIdentityState();
|
|
787
1052
|
$("app").classList.add("hidden");
|
|
788
1053
|
$("appNav").classList.add("hidden");
|
|
789
1054
|
$("gate").classList.remove("hidden");
|
|
@@ -791,8 +1056,8 @@ function showGate(msg) {
|
|
|
791
1056
|
if (AUTH.kind === "clerk") {
|
|
792
1057
|
const signedIn = Boolean(window.Clerk && Clerk.user);
|
|
793
1058
|
$("gateCopy").textContent = signedIn
|
|
794
|
-
? "Signed in with Clerk, but this account cannot open
|
|
795
|
-
: "Sign in with Clerk to open
|
|
1059
|
+
? "Signed in with Clerk, but this account cannot open deployment-wide operator pages."
|
|
1060
|
+
: "Sign in with Clerk to open this operator page.";
|
|
796
1061
|
$("signin").classList.toggle("hidden", signedIn);
|
|
797
1062
|
$("gateSignout").classList.toggle("hidden", !signedIn);
|
|
798
1063
|
} else {
|
|
@@ -800,20 +1065,87 @@ function showGate(msg) {
|
|
|
800
1065
|
}
|
|
801
1066
|
}
|
|
802
1067
|
|
|
1068
|
+
function pageForPath(path) {
|
|
1069
|
+
if (path === "/credentials") return "credentials";
|
|
1070
|
+
if (path === "/activity") return "activity";
|
|
1071
|
+
return "connections";
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function credentialUnavailableCopy(capability) {
|
|
1075
|
+
if (capability === "no_slots") {
|
|
1076
|
+
return "No connectors declare operator-managed credential slots. Connector credentials remain configuration-as-code until a slot is declared.";
|
|
1077
|
+
}
|
|
1078
|
+
if (capability === "vault_not_configured") {
|
|
1079
|
+
return "Credential storage is not configured. Set credentials.encryptionKey before managing connector credentials here.";
|
|
1080
|
+
}
|
|
1081
|
+
return "Credential management requires an eligible Clerk operator. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials.";
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function updateCapabilities() {
|
|
1085
|
+
const credentialsAvailable =
|
|
1086
|
+
DATA?.credentialManagement === "available";
|
|
1087
|
+
$("credentialsNav").classList.toggle("hidden", !credentialsAvailable);
|
|
1088
|
+
$("activityNav").classList.toggle("hidden", !DATA?.activityEnabled);
|
|
1089
|
+
$("credentialUnavailable").classList.toggle("hidden", credentialsAvailable);
|
|
1090
|
+
$("credentialList").classList.toggle("hidden", !credentialsAvailable);
|
|
1091
|
+
$("credentialUnavailable").textContent = credentialsAvailable
|
|
1092
|
+
? ""
|
|
1093
|
+
: credentialUnavailableCopy(DATA?.credentialManagement);
|
|
1094
|
+
$("activityUnavailable").classList.toggle("hidden", Boolean(DATA?.activityEnabled));
|
|
1095
|
+
$("activityAvailable").classList.toggle("hidden", !DATA?.activityEnabled);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function activatePage(page, options) {
|
|
1099
|
+
const next = PAGE_META[page] ? page : "connections";
|
|
1100
|
+
CURRENT_PAGE = next;
|
|
1101
|
+
for (const name of Object.keys(PAGE_META)) {
|
|
1102
|
+
$(name + "View").classList.toggle("hidden", name !== next);
|
|
1103
|
+
const link = $(name + "Nav");
|
|
1104
|
+
if (name === next) link.setAttribute("aria-current", "page");
|
|
1105
|
+
else link.removeAttribute("aria-current");
|
|
1106
|
+
}
|
|
1107
|
+
$("gateHeading").textContent = PAGE_META[next].label;
|
|
1108
|
+
document.title = PAGE_META[next].label + " — " + TITLE_SUFFIX;
|
|
1109
|
+
if (DATA) {
|
|
1110
|
+
updateCapabilities();
|
|
1111
|
+
if (next === "connections") renderConnections();
|
|
1112
|
+
if (next === "credentials") renderCredentials();
|
|
1113
|
+
if (next === "activity" && DATA.activityEnabled && !ACTIVITY_LOADED) {
|
|
1114
|
+
loadActivity(true);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (next !== "credentials") $("credentialList").innerHTML = "";
|
|
1118
|
+
// Focus what is actually on screen. While gated the page views are hidden, so
|
|
1119
|
+
// focusing their heading is a silent no-op that drops focus to <body> and
|
|
1120
|
+
// restarts the next Tab from the top of the document — the gate's own h1 is
|
|
1121
|
+
// the visible heading, and activatePage has just relabelled it.
|
|
1122
|
+
if (options?.focus) $(DATA ? next + "Heading" : "gateHeading").focus();
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function navigateTo(page, href) {
|
|
1126
|
+
history.pushState({ operatorPage: page }, "", href);
|
|
1127
|
+
activatePage(page, { focus: true });
|
|
1128
|
+
}
|
|
1129
|
+
|
|
803
1130
|
async function load() {
|
|
1131
|
+
const generation = SESSION_GENERATION;
|
|
804
1132
|
let token;
|
|
805
1133
|
try {
|
|
806
1134
|
token = await sessionToken();
|
|
807
1135
|
} catch (e) {
|
|
1136
|
+
if (generation !== SESSION_GENERATION) return;
|
|
808
1137
|
return showGate("Could not read the Clerk session: " + e.message);
|
|
809
1138
|
}
|
|
1139
|
+
if (generation !== SESSION_GENERATION) return;
|
|
810
1140
|
if (!token) return showGate("");
|
|
811
1141
|
let res;
|
|
812
1142
|
try {
|
|
813
1143
|
res = await fetch("/ui/data", { headers: { Authorization: "Bearer " + token } });
|
|
814
1144
|
} catch (e) {
|
|
1145
|
+
if (generation !== SESSION_GENERATION) return;
|
|
815
1146
|
return showGate("Network error: " + e.message);
|
|
816
1147
|
}
|
|
1148
|
+
if (generation !== SESSION_GENERATION) return;
|
|
817
1149
|
if (res.status === 401 || res.status === 403) {
|
|
818
1150
|
if (AUTH.kind === "clerk") {
|
|
819
1151
|
return showGate(
|
|
@@ -826,25 +1158,22 @@ async function load() {
|
|
|
826
1158
|
return showGate("Token rejected — enter a valid bearer token.");
|
|
827
1159
|
}
|
|
828
1160
|
if (!res.ok) return showGate("Error " + res.status);
|
|
829
|
-
|
|
1161
|
+
let data;
|
|
1162
|
+
try {
|
|
1163
|
+
data = await res.json();
|
|
1164
|
+
} catch (e) {
|
|
1165
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1166
|
+
return showGate("Operator data could not be read.");
|
|
1167
|
+
}
|
|
1168
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1169
|
+
DATA = data;
|
|
830
1170
|
$("gate").classList.add("hidden");
|
|
831
1171
|
$("app").classList.remove("hidden");
|
|
832
1172
|
$("appNav").classList.remove("hidden");
|
|
833
1173
|
const si = DATA.serverInfo || {};
|
|
834
1174
|
$("serverInfo").textContent = (si.name || ${escapeScriptString(brand.productName)}) + " v" + (si.version || "?");
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function showView(view) {
|
|
840
|
-
const activity = view === "activity";
|
|
841
|
-
$("configView").classList.toggle("hidden", activity);
|
|
842
|
-
$("activityView").classList.toggle("hidden", !activity);
|
|
843
|
-
$("configTab").classList.toggle("active", !activity);
|
|
844
|
-
$("activityTab").classList.toggle("active", activity);
|
|
845
|
-
$("configTab").setAttribute("aria-pressed", String(!activity));
|
|
846
|
-
$("activityTab").setAttribute("aria-pressed", String(activity));
|
|
847
|
-
if (activity && !ACTIVITY_LOADED) loadActivity(true);
|
|
1175
|
+
updateCapabilities();
|
|
1176
|
+
activatePage(CURRENT_PAGE);
|
|
848
1177
|
}
|
|
849
1178
|
|
|
850
1179
|
function actorLabel(actor) {
|
|
@@ -883,12 +1212,21 @@ function renderActivity() {
|
|
|
883
1212
|
}
|
|
884
1213
|
for (const event of visible) {
|
|
885
1214
|
const item = document.createElement("article");
|
|
886
|
-
|
|
887
|
-
|
|
1215
|
+
const outcomeClass = ["success", "error", "timeout"].includes(event.outcome)
|
|
1216
|
+
? event.outcome
|
|
1217
|
+
: "error";
|
|
1218
|
+
item.className = "activity-item " + outcomeClass;
|
|
1219
|
+
const retryCopy = event.attempts > 1
|
|
1220
|
+
? " · " + esc(event.attempts) + " attempts"
|
|
1221
|
+
: "";
|
|
888
1222
|
const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
|
|
889
1223
|
item.innerHTML =
|
|
890
|
-
'<div
|
|
891
|
-
|
|
1224
|
+
'<div class="activity-stamp"><span class="dot ' +
|
|
1225
|
+
(outcomeClass === "success" ? "ok" : "") +
|
|
1226
|
+
'" aria-hidden="true"></span><div><time class="activity-time" datetime="' +
|
|
1227
|
+
esc(event.occurredAt) + '">' +
|
|
1228
|
+
esc(formatDate(event.occurredAt)) +
|
|
1229
|
+
'</time><div class="activity-actor">' + esc(actorLabel(event.actor)) + '</div></div></div>' +
|
|
892
1230
|
'<div><div class="activity-address">' + esc(event.address) +
|
|
893
1231
|
'</div><div class="activity-detail">' + esc(event.source) + retryCopy +
|
|
894
1232
|
errorCopy + '</div></div>' +
|
|
@@ -901,22 +1239,45 @@ function renderActivity() {
|
|
|
901
1239
|
|
|
902
1240
|
async function loadActivity(reset) {
|
|
903
1241
|
if (!DATA?.activityEnabled) return;
|
|
1242
|
+
const sessionGeneration = SESSION_GENERATION;
|
|
1243
|
+
if (reset) ACTIVITY_GENERATION += 1;
|
|
1244
|
+
const activityGeneration = ACTIVITY_GENERATION;
|
|
1245
|
+
const isCurrent = () =>
|
|
1246
|
+
sessionGeneration === SESSION_GENERATION &&
|
|
1247
|
+
activityGeneration === ACTIVITY_GENERATION;
|
|
904
1248
|
$("activityNotice").textContent = "Loading activity…";
|
|
1249
|
+
$("activityNotice").setAttribute("role", "status");
|
|
1250
|
+
$("activityList").setAttribute("aria-busy", "true");
|
|
905
1251
|
$("refreshActivity").disabled = true;
|
|
906
1252
|
$("moreActivity").disabled = true;
|
|
907
1253
|
try {
|
|
908
1254
|
const token = await sessionToken();
|
|
909
|
-
if (!
|
|
1255
|
+
if (!isCurrent()) return;
|
|
1256
|
+
if (!token) return showGate("Your session has expired.");
|
|
910
1257
|
const cursor = reset ? null : ACTIVITY_CURSOR;
|
|
911
1258
|
const params = new URLSearchParams({ limit: "50" });
|
|
912
1259
|
if (cursor) params.set("cursor", cursor);
|
|
913
1260
|
const res = await fetch("/ui/activity?" + params, {
|
|
914
1261
|
headers: { Authorization: "Bearer " + token },
|
|
915
1262
|
});
|
|
1263
|
+
if (!isCurrent()) return;
|
|
916
1264
|
let payload = {};
|
|
917
1265
|
try { payload = await res.json(); } catch (e) {}
|
|
1266
|
+
if (!isCurrent()) return;
|
|
1267
|
+
if (res.status === 401) {
|
|
1268
|
+
return showGate("Your session was not accepted. Sign in again.");
|
|
1269
|
+
}
|
|
1270
|
+
if (res.status === 403) {
|
|
1271
|
+
clearActivityState();
|
|
1272
|
+
$("activityNotice").setAttribute("role", "alert");
|
|
1273
|
+
$("activityNotice").textContent =
|
|
1274
|
+
"This identity may not read activity history.";
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
918
1277
|
if (!res.ok) {
|
|
919
|
-
throw new Error(
|
|
1278
|
+
throw new Error(
|
|
1279
|
+
payload.error || "Activity could not be loaded (" + res.status + ")."
|
|
1280
|
+
);
|
|
920
1281
|
}
|
|
921
1282
|
ACTIVITY = reset
|
|
922
1283
|
? (payload.events || [])
|
|
@@ -926,14 +1287,19 @@ async function loadActivity(reset) {
|
|
|
926
1287
|
$("activityNotice").textContent = "";
|
|
927
1288
|
renderActivity();
|
|
928
1289
|
} catch (e) {
|
|
1290
|
+
if (!isCurrent()) return;
|
|
1291
|
+
$("activityNotice").setAttribute("role", "alert");
|
|
929
1292
|
$("activityNotice").textContent = e.message || "Activity could not be loaded.";
|
|
930
1293
|
} finally {
|
|
931
|
-
|
|
932
|
-
|
|
1294
|
+
if (isCurrent()) {
|
|
1295
|
+
$("activityList").setAttribute("aria-busy", "false");
|
|
1296
|
+
$("refreshActivity").disabled = false;
|
|
1297
|
+
$("moreActivity").disabled = false;
|
|
1298
|
+
}
|
|
933
1299
|
}
|
|
934
1300
|
}
|
|
935
1301
|
|
|
936
|
-
function
|
|
1302
|
+
function renderConnections() {
|
|
937
1303
|
const q = $("filter").value.trim().toLowerCase();
|
|
938
1304
|
const list = $("list");
|
|
939
1305
|
list.innerHTML = "";
|
|
@@ -981,66 +1347,8 @@ function render() {
|
|
|
981
1347
|
esc(c.authorizationUrl) + "</p>";
|
|
982
1348
|
}
|
|
983
1349
|
if (c.credential) {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
const removable = configured || Boolean(cred.removable);
|
|
987
|
-
const state = configured
|
|
988
|
-
? "configured · ••••" + esc(cred.lastFour || "")
|
|
989
|
-
: "not configured";
|
|
990
|
-
const updated = configured && cred.updatedAt
|
|
991
|
-
? " · updated " + esc(formatDate(cred.updatedAt))
|
|
992
|
-
: "";
|
|
993
|
-
head += '<section class="credential" aria-label="' + esc(cred.label) + '">';
|
|
994
|
-
head += '<div class="credential-head"><span class="credential-label">' +
|
|
995
|
-
esc(cred.label) + '</span><span class="credential-state">' + state +
|
|
996
|
-
updated + "</span></div>";
|
|
997
|
-
if (cred.description) {
|
|
998
|
-
head += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
|
|
999
|
-
}
|
|
1000
|
-
if (cred.error) {
|
|
1001
|
-
head += '<div class="msg">' + esc(cred.error) + "</div>";
|
|
1002
|
-
}
|
|
1003
|
-
if (AUTH.kind === "clerk") {
|
|
1004
|
-
head += '<div class="credential-actions">';
|
|
1005
|
-
head += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
|
|
1006
|
-
esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
|
|
1007
|
-
if (configured && cred.testable) {
|
|
1008
|
-
head += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
|
|
1009
|
-
esc(c.id) + '">Test</button>';
|
|
1010
|
-
}
|
|
1011
|
-
if (removable) {
|
|
1012
|
-
head += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
|
|
1013
|
-
esc(c.id) + '">Remove</button>';
|
|
1014
|
-
}
|
|
1015
|
-
head += "</div>";
|
|
1016
|
-
head += '<div class="credential-form hidden" data-credential-form="' +
|
|
1017
|
-
esc(c.id) + '">';
|
|
1018
|
-
if (cred.fields && cred.fields.length) {
|
|
1019
|
-
head += '<div class="credential-fields">';
|
|
1020
|
-
for (const field of cred.fields) {
|
|
1021
|
-
head += '<div class="credential-field"><label>' +
|
|
1022
|
-
esc(field.label) + '</label><input type="' +
|
|
1023
|
-
esc(field.inputType || "password") + '" data-credential-field="' +
|
|
1024
|
-
esc(field.name) + '" aria-label="' + esc(field.label) +
|
|
1025
|
-
'" placeholder="' + esc(field.placeholder || field.label) +
|
|
1026
|
-
'" autocomplete="' +
|
|
1027
|
-
(field.inputType === "password" ? "new-password" : "off") +
|
|
1028
|
-
'" autocapitalize="none" spellcheck="false"></div>';
|
|
1029
|
-
}
|
|
1030
|
-
head += "</div>";
|
|
1031
|
-
} else {
|
|
1032
|
-
head += '<input type="password" data-credential-input="' +
|
|
1033
|
-
esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
|
|
1034
|
-
esc(cred.placeholder || "Paste credential") +
|
|
1035
|
-
'" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
|
|
1036
|
-
}
|
|
1037
|
-
head += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
|
|
1038
|
-
esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
|
|
1039
|
-
esc(c.id) + '">Cancel</button></div>';
|
|
1040
|
-
} else {
|
|
1041
|
-
head += '<p class="credential-copy meta">Team sign in is required to manage this credential.</p>';
|
|
1042
|
-
}
|
|
1043
|
-
head += "</section>";
|
|
1350
|
+
head += '<p class="connector-auth"><a class="linklike" href="/credentials" ' +
|
|
1351
|
+
'data-operator-page="credentials">Manage credential →</a></p>';
|
|
1044
1352
|
}
|
|
1045
1353
|
let body = "";
|
|
1046
1354
|
if (tools.length) {
|
|
@@ -1058,12 +1366,122 @@ function render() {
|
|
|
1058
1366
|
list.appendChild(el);
|
|
1059
1367
|
}
|
|
1060
1368
|
if (!list.children.length) {
|
|
1061
|
-
list.innerHTML = '<p class="empty">
|
|
1369
|
+
list.innerHTML = '<p class="empty">' +
|
|
1370
|
+
(q
|
|
1371
|
+
? "No connectors or tools match this filter."
|
|
1372
|
+
: "No connectors are declared in this deployment.") +
|
|
1373
|
+
"</p>";
|
|
1062
1374
|
}
|
|
1063
1375
|
}
|
|
1064
1376
|
|
|
1065
|
-
|
|
1377
|
+
function renderCredentials() {
|
|
1378
|
+
const list = $("credentialList");
|
|
1379
|
+
list.innerHTML = "";
|
|
1380
|
+
if (DATA.credentialManagement !== "available") return;
|
|
1381
|
+
for (const c of DATA.connectors.filter((connector) => connector.credential)) {
|
|
1382
|
+
const cred = c.credential;
|
|
1383
|
+
const configured = Boolean(cred.configured);
|
|
1384
|
+
const removable = configured || Boolean(cred.removable);
|
|
1385
|
+
const state = configured
|
|
1386
|
+
? cred.fields?.length
|
|
1387
|
+
? "configured"
|
|
1388
|
+
: "configured · ••••" + esc(cred.lastFour || "")
|
|
1389
|
+
: "not configured";
|
|
1390
|
+
const updated = configured && cred.updatedAt
|
|
1391
|
+
? " · updated " + esc(formatDate(cred.updatedAt))
|
|
1392
|
+
: "";
|
|
1393
|
+
const el = document.createElement("section");
|
|
1394
|
+
el.className = "credential-card";
|
|
1395
|
+
el.id = "credential-" + c.id;
|
|
1396
|
+
el.setAttribute("aria-labelledby", "credential-title-" + c.id);
|
|
1397
|
+
let body = '<div class="credential-head"><div class="connector-title">' +
|
|
1398
|
+
'<span class="dot ' + (configured ? "ok" : "auth_required") +
|
|
1399
|
+
'" aria-hidden="true"></span><h2 id="credential-title-' + esc(c.id) + '">' +
|
|
1400
|
+
esc(c.title || c.id) + '</h2></div><span class="credential-state">' +
|
|
1401
|
+
state + updated + "</span></div>";
|
|
1402
|
+
body += '<p class="mono">' + esc(c.id) + " · " + esc(cred.label) + "</p>";
|
|
1403
|
+
if (cred.description) {
|
|
1404
|
+
body += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
|
|
1405
|
+
}
|
|
1406
|
+
if (cred.fields?.length) {
|
|
1407
|
+
body += '<div class="credential-field-summary">';
|
|
1408
|
+
for (const field of cred.fields) {
|
|
1409
|
+
const fieldState = field.configured
|
|
1410
|
+
? "configured · ••••" + esc(field.lastFour || "") +
|
|
1411
|
+
(field.updatedAt ? " · updated " + esc(formatDate(field.updatedAt)) : "")
|
|
1412
|
+
: "not configured";
|
|
1413
|
+
body += '<div><span>' + esc(field.label) +
|
|
1414
|
+
'</span><span class="meta">' + fieldState + "</span></div>";
|
|
1415
|
+
}
|
|
1416
|
+
body += "</div>";
|
|
1417
|
+
}
|
|
1418
|
+
if (c.credentialCheck) {
|
|
1419
|
+
const check = c.credentialCheck;
|
|
1420
|
+
const verdict = check.state === "ok"
|
|
1421
|
+
? "healthy"
|
|
1422
|
+
: check.state === "auth_required"
|
|
1423
|
+
? "needs authorization"
|
|
1424
|
+
: "check failed";
|
|
1425
|
+
body += '<p class="connector-check meta">Liveness: ' + esc(verdict) +
|
|
1426
|
+
" · " + esc(formatDate(check.checkedAt)) +
|
|
1427
|
+
(check.message ? " — " + esc(check.message) : "") + "</p>";
|
|
1428
|
+
}
|
|
1429
|
+
if (cred.error) body += '<div class="msg">' + esc(cred.error) + "</div>";
|
|
1430
|
+
// Leftover stored fields are not an error — the credential still works, so
|
|
1431
|
+
// this stays muted copy rather than the msg block an actual failure earns.
|
|
1432
|
+
if (cred.notice) {
|
|
1433
|
+
body += '<p class="credential-copy meta">' + esc(cred.notice) + "</p>";
|
|
1434
|
+
}
|
|
1435
|
+
body += '<div class="credential-actions">';
|
|
1436
|
+
body += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
|
|
1437
|
+
esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
|
|
1438
|
+
if (configured && cred.testable) {
|
|
1439
|
+
body += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
|
|
1440
|
+
esc(c.id) + '">Test</button>';
|
|
1441
|
+
}
|
|
1442
|
+
if (removable) {
|
|
1443
|
+
body += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
|
|
1444
|
+
esc(c.id) + '">Remove</button>';
|
|
1445
|
+
}
|
|
1446
|
+
body += "</div>";
|
|
1447
|
+
body += '<div class="credential-form hidden" data-credential-form="' +
|
|
1448
|
+
esc(c.id) + '">';
|
|
1449
|
+
if (cred.fields && cred.fields.length) {
|
|
1450
|
+
body += '<div class="credential-fields">';
|
|
1451
|
+
for (let index = 0; index < cred.fields.length; index += 1) {
|
|
1452
|
+
const field = cred.fields[index];
|
|
1453
|
+
const inputId = "credential-input-" + c.id + "-" + index;
|
|
1454
|
+
body += '<div class="credential-field"><label for="' + esc(inputId) + '">' +
|
|
1455
|
+
esc(field.label) + '</label><input id="' + esc(inputId) + '" type="' +
|
|
1456
|
+
esc(field.inputType || "password") + '" data-credential-field="' +
|
|
1457
|
+
esc(field.name) + '" placeholder="' + esc(field.placeholder || field.label) +
|
|
1458
|
+
'" autocomplete="' +
|
|
1459
|
+
(field.inputType === "password" ? "new-password" : "off") +
|
|
1460
|
+
'" autocapitalize="none" spellcheck="false"></div>';
|
|
1461
|
+
}
|
|
1462
|
+
body += "</div>";
|
|
1463
|
+
} else {
|
|
1464
|
+
const inputId = "credential-input-" + c.id;
|
|
1465
|
+
body += '<label class="visually-hidden" for="' + esc(inputId) + '">' +
|
|
1466
|
+
esc(cred.label) + '</label><input id="' + esc(inputId) +
|
|
1467
|
+
'" type="password" data-credential-input="' +
|
|
1468
|
+
esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
|
|
1469
|
+
esc(cred.placeholder || "Paste credential") +
|
|
1470
|
+
'" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
|
|
1471
|
+
}
|
|
1472
|
+
body += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
|
|
1473
|
+
esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
|
|
1474
|
+
esc(c.id) + '">Cancel</button></div>';
|
|
1475
|
+
el.innerHTML = body;
|
|
1476
|
+
list.appendChild(el);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
async function credentialRequest(connector, method, action, body, generation) {
|
|
1066
1481
|
const token = await sessionToken();
|
|
1482
|
+
if (generation !== SESSION_GENERATION) {
|
|
1483
|
+
throw new Error("The operator session changed.");
|
|
1484
|
+
}
|
|
1067
1485
|
if (!token) throw new Error("Your Clerk session has expired.");
|
|
1068
1486
|
const suffix = action ? "/" + action : "";
|
|
1069
1487
|
const res = await fetch(
|
|
@@ -1089,12 +1507,13 @@ function credentialForm(connector) {
|
|
|
1089
1507
|
CSS.escape(connector) + '"]');
|
|
1090
1508
|
}
|
|
1091
1509
|
|
|
1092
|
-
$("
|
|
1510
|
+
$("credentialList").onclick = async (event) => {
|
|
1093
1511
|
const button = event.target.closest("[data-credential-action]");
|
|
1094
1512
|
if (!button) return;
|
|
1095
1513
|
const connector = button.dataset.connector;
|
|
1096
1514
|
const action = button.dataset.credentialAction;
|
|
1097
1515
|
const form = credentialForm(connector);
|
|
1516
|
+
const generation = SESSION_GENERATION;
|
|
1098
1517
|
|
|
1099
1518
|
if (action === "edit") {
|
|
1100
1519
|
form.classList.remove("hidden");
|
|
@@ -1104,6 +1523,10 @@ $("list").onclick = async (event) => {
|
|
|
1104
1523
|
if (action === "cancel") {
|
|
1105
1524
|
form.querySelectorAll("input").forEach((input) => { input.value = ""; });
|
|
1106
1525
|
form.classList.add("hidden");
|
|
1526
|
+
document.querySelector(
|
|
1527
|
+
'[data-credential-action="edit"][data-connector="' +
|
|
1528
|
+
CSS.escape(connector) + '"]'
|
|
1529
|
+
)?.focus();
|
|
1107
1530
|
return;
|
|
1108
1531
|
}
|
|
1109
1532
|
if (action === "remove" && !window.confirm(
|
|
@@ -1111,6 +1534,7 @@ $("list").onclick = async (event) => {
|
|
|
1111
1534
|
)) return;
|
|
1112
1535
|
|
|
1113
1536
|
setNotice("");
|
|
1537
|
+
$("credentialList").setAttribute("aria-busy", "true");
|
|
1114
1538
|
const buttons = [...document.querySelectorAll(
|
|
1115
1539
|
'[data-connector="' + CSS.escape(connector) + '"]'
|
|
1116
1540
|
)];
|
|
@@ -1125,42 +1549,65 @@ $("list").onclick = async (event) => {
|
|
|
1125
1549
|
if (!value) throw new Error("Complete every credential field before saving.");
|
|
1126
1550
|
values[input.dataset.credentialField] = value;
|
|
1127
1551
|
}
|
|
1128
|
-
await credentialRequest(connector, "PUT", "", { values });
|
|
1552
|
+
await credentialRequest(connector, "PUT", "", { values }, generation);
|
|
1129
1553
|
} else {
|
|
1130
1554
|
const input = form.querySelector("[data-credential-input]");
|
|
1131
1555
|
const value = input.value.trim();
|
|
1132
1556
|
if (!value) throw new Error("Paste a credential before saving.");
|
|
1133
|
-
await credentialRequest(connector, "PUT", "", { value });
|
|
1557
|
+
await credentialRequest(connector, "PUT", "", { value }, generation);
|
|
1134
1558
|
}
|
|
1559
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1135
1560
|
form.querySelectorAll("input").forEach((input) => { input.value = ""; });
|
|
1136
1561
|
setNotice("Credential saved.");
|
|
1137
1562
|
await load();
|
|
1563
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1564
|
+
$("credentialNotice").focus();
|
|
1138
1565
|
} else if (action === "remove") {
|
|
1139
|
-
await credentialRequest(connector, "DELETE");
|
|
1566
|
+
await credentialRequest(connector, "DELETE", "", null, generation);
|
|
1567
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1140
1568
|
setNotice("Credential removed.");
|
|
1141
1569
|
await load();
|
|
1570
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1571
|
+
$("credentialNotice").focus();
|
|
1142
1572
|
} else if (action === "test") {
|
|
1143
|
-
const result = await credentialRequest(
|
|
1573
|
+
const result = await credentialRequest(
|
|
1574
|
+
connector,
|
|
1575
|
+
"POST",
|
|
1576
|
+
"test",
|
|
1577
|
+
null,
|
|
1578
|
+
generation,
|
|
1579
|
+
);
|
|
1580
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1144
1581
|
setNotice(
|
|
1145
1582
|
result.message || (result.ok ? "Credential is valid." : "Credential test failed."),
|
|
1146
1583
|
!result.ok,
|
|
1147
1584
|
);
|
|
1148
1585
|
}
|
|
1149
1586
|
} catch (e) {
|
|
1587
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1150
1588
|
setNotice(e.message || "Credential action failed.", true);
|
|
1151
1589
|
} finally {
|
|
1152
|
-
|
|
1590
|
+
if (generation === SESSION_GENERATION) {
|
|
1591
|
+
$("credentialList").setAttribute("aria-busy", "false");
|
|
1592
|
+
buttons.forEach((item) => { item.disabled = false; });
|
|
1593
|
+
}
|
|
1153
1594
|
}
|
|
1154
1595
|
};
|
|
1155
1596
|
|
|
1156
|
-
$("save").onclick = () => {
|
|
1597
|
+
$("save").onclick = async () => {
|
|
1157
1598
|
const v = $("token").value.trim();
|
|
1158
1599
|
if (!v) return;
|
|
1600
|
+
clearIdentityState();
|
|
1159
1601
|
localStorage.setItem(KEY, v);
|
|
1160
1602
|
$("token").value = "";
|
|
1161
|
-
load();
|
|
1603
|
+
await load();
|
|
1604
|
+
if (DATA) $(CURRENT_PAGE + "Heading").focus();
|
|
1605
|
+
};
|
|
1606
|
+
$("change").onclick = () => {
|
|
1607
|
+
localStorage.removeItem(KEY);
|
|
1608
|
+
showGate("");
|
|
1609
|
+
$("token").focus();
|
|
1162
1610
|
};
|
|
1163
|
-
$("change").onclick = () => { localStorage.removeItem(KEY); showGate(""); };
|
|
1164
1611
|
$("copyMcpUrl").onclick = async () => {
|
|
1165
1612
|
const button = $("copyMcpUrl");
|
|
1166
1613
|
try {
|
|
@@ -1175,14 +1622,35 @@ $("signin").onclick = () => Clerk.redirectToSignIn({
|
|
|
1175
1622
|
signInFallbackRedirectUrl: window.location.href,
|
|
1176
1623
|
signUpFallbackRedirectUrl: window.location.href,
|
|
1177
1624
|
});
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
$("
|
|
1625
|
+
function signOut() {
|
|
1626
|
+
clearIdentityState();
|
|
1627
|
+
return Clerk.signOut({ redirectUrl: window.location.href });
|
|
1628
|
+
}
|
|
1629
|
+
$("gateSignout").onclick = signOut;
|
|
1630
|
+
$("signout").onclick = signOut;
|
|
1631
|
+
$("filter").oninput = () => { if (DATA) renderConnections(); };
|
|
1183
1632
|
$("refreshActivity").onclick = () => loadActivity(true);
|
|
1184
1633
|
$("moreActivity").onclick = () => loadActivity(false);
|
|
1185
1634
|
$("activitySearch").oninput = () => renderActivity();
|
|
1635
|
+
document.addEventListener("click", (event) => {
|
|
1636
|
+
const link = event.target.closest("a[data-operator-page]");
|
|
1637
|
+
if (
|
|
1638
|
+
!link ||
|
|
1639
|
+
event.defaultPrevented ||
|
|
1640
|
+
event.button !== 0 ||
|
|
1641
|
+
event.metaKey ||
|
|
1642
|
+
event.ctrlKey ||
|
|
1643
|
+
event.shiftKey ||
|
|
1644
|
+
event.altKey
|
|
1645
|
+
) return;
|
|
1646
|
+
const target = new URL(link.href, window.location.href);
|
|
1647
|
+
if (target.origin !== window.location.origin) return;
|
|
1648
|
+
event.preventDefault();
|
|
1649
|
+
navigateTo(link.dataset.operatorPage, target.pathname + target.search + target.hash);
|
|
1650
|
+
});
|
|
1651
|
+
window.addEventListener("popstate", () => {
|
|
1652
|
+
activatePage(pageForPath(window.location.pathname), { focus: true });
|
|
1653
|
+
});
|
|
1186
1654
|
|
|
1187
1655
|
async function init() {
|
|
1188
1656
|
if (AUTH.kind === "clerk") {
|
|
@@ -1206,6 +1674,7 @@ async function init() {
|
|
|
1206
1674
|
$("tokenGate").classList.remove("hidden");
|
|
1207
1675
|
$("change").classList.remove("hidden");
|
|
1208
1676
|
}
|
|
1677
|
+
activatePage(pageForPath(window.location.pathname));
|
|
1209
1678
|
await load();
|
|
1210
1679
|
}
|
|
1211
1680
|
|