@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/src/ui.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
credentialTestRule,
|
|
3
|
+
describeUndeclaredCredentialFields,
|
|
4
|
+
storedCredentialShape,
|
|
5
|
+
} from "./credentials.js";
|
|
1
6
|
import type { CredentialVault } from "./credentials.js";
|
|
7
|
+
import { closeConnectorScope } from "./connector-scope.js";
|
|
2
8
|
import type { Registry } from "./registry.js";
|
|
3
9
|
import type { ConnectaBranding, UiAuthConfig } from "./types.js";
|
|
4
10
|
|
|
@@ -66,6 +72,20 @@ export function resolveBranding(
|
|
|
66
72
|
};
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Whether the operator meant to supply a value here — the question every
|
|
77
|
+
* dropped-URL warning asks before naming a field, and one definition so the
|
|
78
|
+
* branding and `uiAuth` warnings cannot answer it differently. A non-string
|
|
79
|
+
* counts as set: the intent was there and is exactly what the warning reports
|
|
80
|
+
* on. A blank or whitespace-only string does not; that is indistinguishable
|
|
81
|
+
* from leaving the field alone, and both take the default silently.
|
|
82
|
+
*/
|
|
83
|
+
function isSetUrlValue(value: unknown): boolean {
|
|
84
|
+
return typeof value === "string"
|
|
85
|
+
? trimmedString(value) !== undefined
|
|
86
|
+
: value !== undefined && value !== null;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
/**
|
|
70
90
|
* Names of the branding URLs the operator set that failed their gate and were
|
|
71
91
|
* replaced by a default. Lives beside the gates so the startup warning cannot
|
|
@@ -75,17 +95,15 @@ export function resolveBranding(
|
|
|
75
95
|
export function droppedBrandingUrls(branding?: ConnectaBranding): string[] {
|
|
76
96
|
if (!branding) return [];
|
|
77
97
|
const resolved = resolveBranding(branding);
|
|
78
|
-
// A non-string still counts as "set": the operator meant to supply a URL, and
|
|
79
|
-
// that intent is exactly what the warning reports on. A blank string does not.
|
|
80
|
-
const isSet = (value: unknown) =>
|
|
81
|
-
typeof value === "string"
|
|
82
|
-
? trimmedString(value) !== undefined
|
|
83
|
-
: value !== undefined && value !== null;
|
|
84
98
|
const faviconHref = branding.favicon?.href;
|
|
85
99
|
return [
|
|
86
|
-
...(
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
...(isSetUrlValue(branding.productUrl) && !resolved.productUrl
|
|
101
|
+
? ["productUrl"]
|
|
102
|
+
: []),
|
|
103
|
+
...(isSetUrlValue(branding.ownerUrl) && !resolved.ownerUrl
|
|
104
|
+
? ["ownerUrl"]
|
|
105
|
+
: []),
|
|
106
|
+
...(isSetUrlValue(faviconHref) &&
|
|
89
107
|
trimmedString(faviconHref) !== resolved.faviconHref
|
|
90
108
|
? ["favicon.href"]
|
|
91
109
|
: []),
|
|
@@ -135,8 +153,8 @@ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
|
|
|
135
153
|
* default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
|
|
136
154
|
* reject — and it is kept narrow on both ends.
|
|
137
155
|
*
|
|
138
|
-
* Root-relative only, because
|
|
139
|
-
* depths and a document-relative path would resolve differently
|
|
156
|
+
* Root-relative only, because operator and OAuth callback pages sit at
|
|
157
|
+
* different depths and a document-relative path would resolve differently.
|
|
140
158
|
*
|
|
141
159
|
* "Root-relative" is enforced structurally: exactly one leading `/` followed by
|
|
142
160
|
* a character that is neither `/` nor `\`. Both of those would make the value an
|
|
@@ -159,20 +177,26 @@ export function isSafeIconHref(href: unknown): boolean {
|
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
/**
|
|
162
|
-
* True only for an absolute `https:` URL — the gate
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* left to remember.
|
|
180
|
+
* True only for an absolute `https:` URL — the gate every `uiAuth` URL passes:
|
|
181
|
+
* `frontendApiUrl`, which becomes the operator shell's sign-in loader source,
|
|
182
|
+
* and `signInUrl`/`signUpUrl`, which ClerkJS uses as *navigation targets* when
|
|
183
|
+
* the operator signs in. With those three gated, no operator-config value
|
|
184
|
+
* reaches the browser in a URL position — attribute or navigation — without
|
|
185
|
+
* validation, and there is no exception left to remember.
|
|
168
186
|
*
|
|
169
|
-
* Stricter than `isSafeHttpUrl` on purpose
|
|
170
|
-
*
|
|
171
|
-
* derives it from the publishable key and Clerk's Frontend API is
|
|
172
|
-
*
|
|
173
|
-
* browser's mixed-content rules had not already blocked
|
|
187
|
+
* Stricter than `isSafeHttpUrl` on purpose: no `http:` carve-out, no loopback
|
|
188
|
+
* carve-out, and no relative form. Nobody types `frontendApiUrl` — the shipped
|
|
189
|
+
* Clerk adapter derives it from the publishable key, and Clerk's Frontend API is
|
|
190
|
+
* always https — and a cleartext script source on an operator page would be a
|
|
191
|
+
* downgrade even where a browser's mixed-content rules had not already blocked
|
|
192
|
+
* it. `signInUrl`/`signUpUrl` *are* typed by the operator, but what belongs
|
|
193
|
+
* there is a hosted Account Portal address (`https://accounts.<domain>` or
|
|
194
|
+
* `https://<slug>.accounts.dev`), which is https as well; `http:` would carry a
|
|
195
|
+
* sign-in over cleartext, and a path relative to this origin is meaningless
|
|
196
|
+
* because this server hosts no sign-in page of its own. So the looser gate would
|
|
197
|
+
* buy nothing real, and the same strictness holds for all three.
|
|
174
198
|
*/
|
|
175
|
-
export function
|
|
199
|
+
export function isSafeHttpsUrl(url: unknown): boolean {
|
|
176
200
|
if (typeof url !== "string") return false;
|
|
177
201
|
try {
|
|
178
202
|
return new URL(url).protocol === "https:";
|
|
@@ -186,14 +210,31 @@ export function isSafeScriptSrcUrl(url: unknown): boolean {
|
|
|
186
210
|
* gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
|
|
187
211
|
* startup warning cannot then drift from what rendering actually drops. Every
|
|
188
212
|
* field is read defensively rather than trusted, because a custom `InboundAuth`
|
|
189
|
-
* is untyped at a JS call site — `
|
|
213
|
+
* is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
|
|
190
214
|
* `uiAuth` that is not the clerk shape is reported as nothing to warn about.
|
|
215
|
+
*
|
|
216
|
+
* `frontendApiUrl` is required, so anything that fails its gate is a drop.
|
|
217
|
+
* `signInUrl` and `signUpUrl` are optional, so only a value the operator
|
|
218
|
+
* *supplied* and the gate then rejected is worth a warning — an unset field
|
|
219
|
+
* took no default away from anyone. `isSetUrlValue` decides that, the same way
|
|
220
|
+
* and for the same reasons it decides it for the branding URLs: a warning that
|
|
221
|
+
* fires for one and not the other would be reporting on the field rather than
|
|
222
|
+
* on the operator's intent. Rendering is not consulted for this: it drops on
|
|
223
|
+
* the gate alone, and a blank string fails that gate too — it is simply not
|
|
224
|
+
* *reported*, because a blank is indistinguishable from leaving the field
|
|
225
|
+
* alone.
|
|
191
226
|
*/
|
|
192
227
|
export function droppedUiAuthUrls(uiAuth?: UiAuthConfig): string[] {
|
|
193
228
|
if (!uiAuth || uiAuth.kind !== "clerk") return [];
|
|
194
|
-
return
|
|
195
|
-
? []
|
|
196
|
-
|
|
229
|
+
return [
|
|
230
|
+
...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
|
|
231
|
+
...(isSetUrlValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
|
|
232
|
+
? ["uiAuth.signInUrl"]
|
|
233
|
+
: []),
|
|
234
|
+
...(isSetUrlValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
|
|
235
|
+
? ["uiAuth.signUpUrl"]
|
|
236
|
+
: []),
|
|
237
|
+
];
|
|
197
238
|
}
|
|
198
239
|
|
|
199
240
|
export interface UiTool {
|
|
@@ -243,6 +284,12 @@ export interface UiConnector {
|
|
|
243
284
|
updatedAt?: string;
|
|
244
285
|
testable: boolean;
|
|
245
286
|
error?: string;
|
|
287
|
+
/**
|
|
288
|
+
* Something true and non-blocking about a working credential — today, that
|
|
289
|
+
* the vault still holds fields the connector has stopped declaring.
|
|
290
|
+
* Distinct from `error`, which means the credential cannot be used.
|
|
291
|
+
*/
|
|
292
|
+
notice?: string;
|
|
246
293
|
};
|
|
247
294
|
}
|
|
248
295
|
|
|
@@ -250,6 +297,46 @@ export interface UiData {
|
|
|
250
297
|
serverInfo: { name: string; version: string };
|
|
251
298
|
connectors: UiConnector[];
|
|
252
299
|
activityEnabled: boolean;
|
|
300
|
+
credentialManagement: CredentialManagementCapability;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export type CredentialManagementCapability =
|
|
304
|
+
| "available"
|
|
305
|
+
| "requires_clerk"
|
|
306
|
+
| "vault_not_configured"
|
|
307
|
+
| "no_slots";
|
|
308
|
+
|
|
309
|
+
export type OperatorPage = "connections" | "credentials" | "activity";
|
|
310
|
+
|
|
311
|
+
const OPERATOR_PAGE_LABELS: Readonly<Record<OperatorPage, string>> = {
|
|
312
|
+
connections: "Connections",
|
|
313
|
+
credentials: "Credentials",
|
|
314
|
+
activity: "Activity",
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export function operatorPageForPath(path: string): OperatorPage | undefined {
|
|
318
|
+
if (path === "/") return "connections";
|
|
319
|
+
if (path === "/credentials") return "credentials";
|
|
320
|
+
if (path === "/activity") return "activity";
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function operatorPageTitle(
|
|
325
|
+
page: OperatorPage,
|
|
326
|
+
configuredTitle: string,
|
|
327
|
+
): string {
|
|
328
|
+
return `${OPERATOR_PAGE_LABELS[page]} — ${configuredTitle}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function credentialManagementCapability(input: {
|
|
332
|
+
eligibleClerkOperator: boolean;
|
|
333
|
+
hasCredentialSlots: boolean;
|
|
334
|
+
hasCredentialVault: boolean;
|
|
335
|
+
}): CredentialManagementCapability {
|
|
336
|
+
if (!input.eligibleClerkOperator) return "requires_clerk";
|
|
337
|
+
if (!input.hasCredentialSlots) return "no_slots";
|
|
338
|
+
if (!input.hasCredentialVault) return "vault_not_configured";
|
|
339
|
+
return "available";
|
|
253
340
|
}
|
|
254
341
|
|
|
255
342
|
export interface FilteredUiConnector {
|
|
@@ -301,10 +388,14 @@ export async function buildUiData(
|
|
|
301
388
|
serverInfo: { name: string; version: string },
|
|
302
389
|
credentialVault?: CredentialVault,
|
|
303
390
|
activityEnabled = false,
|
|
391
|
+
credentialManagement: CredentialManagementCapability = credentialVault
|
|
392
|
+
? "available"
|
|
393
|
+
: "requires_clerk",
|
|
304
394
|
): Promise<UiData> {
|
|
305
395
|
const requestScope = {};
|
|
396
|
+
const connectorSet = registry.listConnectors();
|
|
306
397
|
const connectors = await Promise.all(
|
|
307
|
-
|
|
398
|
+
connectorSet.map(async (c): Promise<UiConnector> => {
|
|
308
399
|
const status = await registry.statusFor(c.id, baseUrl, requestScope);
|
|
309
400
|
const credentialCheck = await registry.credentialHealthFor(c.id);
|
|
310
401
|
let tools: UiTool[] = [];
|
|
@@ -328,6 +419,10 @@ export async function buildUiData(
|
|
|
328
419
|
}
|
|
329
420
|
let credential: UiConnector["credential"];
|
|
330
421
|
if (c.credential && credentialVault) {
|
|
422
|
+
// One rule, shared with the test route: only the hook matching the
|
|
423
|
+
// declared credential shape can run, so the button is offered only
|
|
424
|
+
// where a click can succeed (src/credentials.ts).
|
|
425
|
+
const testRule = credentialTestRule(c);
|
|
331
426
|
const credentialFields = (
|
|
332
427
|
metadata?: Awaited<ReturnType<CredentialVault["metadata"]>>,
|
|
333
428
|
) =>
|
|
@@ -355,6 +450,10 @@ export async function buildUiData(
|
|
|
355
450
|
try {
|
|
356
451
|
const metadata = await credentialVault.metadata(c.id);
|
|
357
452
|
const fields = credentialFields(metadata);
|
|
453
|
+
const shape = storedCredentialShape(
|
|
454
|
+
c.credential,
|
|
455
|
+
metadata?.fields ?? null,
|
|
456
|
+
);
|
|
358
457
|
credential = {
|
|
359
458
|
label: c.credential.label,
|
|
360
459
|
...(c.credential.description
|
|
@@ -364,9 +463,7 @@ export async function buildUiData(
|
|
|
364
463
|
? { placeholder: c.credential.placeholder }
|
|
365
464
|
: {}),
|
|
366
465
|
...(fields?.length ? { fields } : {}),
|
|
367
|
-
configured:
|
|
368
|
-
? fields.every((field) => field.configured)
|
|
369
|
-
: Boolean(metadata),
|
|
466
|
+
configured: shape.state === "valid",
|
|
370
467
|
removable: Boolean(metadata),
|
|
371
468
|
...(metadata
|
|
372
469
|
? {
|
|
@@ -374,7 +471,21 @@ export async function buildUiData(
|
|
|
374
471
|
updatedAt: metadata.updatedAt,
|
|
375
472
|
}
|
|
376
473
|
: {}),
|
|
377
|
-
testable:
|
|
474
|
+
testable:
|
|
475
|
+
testRule.mode !== null && shape.state !== "mismatch",
|
|
476
|
+
...(shape.state === "mismatch"
|
|
477
|
+
? { error: shape.message }
|
|
478
|
+
: {}),
|
|
479
|
+
// A dropped field leaves its secret in the vault, and the field
|
|
480
|
+
// list below only renders fields the connector still declares —
|
|
481
|
+
// so without this line there is nowhere an operator could see it.
|
|
482
|
+
...(shape.state === "valid" && shape.undeclared.length
|
|
483
|
+
? {
|
|
484
|
+
notice: describeUndeclaredCredentialFields(
|
|
485
|
+
shape.undeclared,
|
|
486
|
+
),
|
|
487
|
+
}
|
|
488
|
+
: {}),
|
|
378
489
|
};
|
|
379
490
|
} catch {
|
|
380
491
|
const fields = credentialFields();
|
|
@@ -389,7 +500,7 @@ export async function buildUiData(
|
|
|
389
500
|
...(fields?.length ? { fields } : {}),
|
|
390
501
|
configured: false,
|
|
391
502
|
removable: true,
|
|
392
|
-
testable:
|
|
503
|
+
testable: testRule.mode !== null,
|
|
393
504
|
error: "Stored credential could not be read.",
|
|
394
505
|
};
|
|
395
506
|
}
|
|
@@ -419,8 +530,22 @@ export async function buildUiData(
|
|
|
419
530
|
...(credential ? { credential } : {}),
|
|
420
531
|
};
|
|
421
532
|
}),
|
|
422
|
-
)
|
|
423
|
-
|
|
533
|
+
).finally(async () => {
|
|
534
|
+
await Promise.all(
|
|
535
|
+
connectorSet.map((connector) =>
|
|
536
|
+
closeConnectorScope(
|
|
537
|
+
connector,
|
|
538
|
+
registry.contextFor(connector.id, baseUrl, requestScope),
|
|
539
|
+
),
|
|
540
|
+
),
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
return {
|
|
544
|
+
serverInfo,
|
|
545
|
+
connectors,
|
|
546
|
+
activityEnabled,
|
|
547
|
+
credentialManagement,
|
|
548
|
+
};
|
|
424
549
|
}
|
|
425
550
|
|
|
426
551
|
function escapeHtmlAttr(value: string): string {
|
|
@@ -439,16 +564,15 @@ function jsonForInlineScript(value: unknown): string {
|
|
|
439
564
|
}
|
|
440
565
|
|
|
441
566
|
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* normal sign-in flow and a short-lived session token; bearer-only deployments
|
|
445
|
-
* retain the manual-token fallback.
|
|
567
|
+
* Every operator page serves this same data-free shell. Connector, credential,
|
|
568
|
+
* and activity data arrives only through the authenticated `/ui/*` APIs.
|
|
446
569
|
*/
|
|
447
570
|
export function renderUiHtml(
|
|
448
571
|
uiAuth?: UiAuthConfig,
|
|
449
572
|
mcpUrl = "/mcp",
|
|
450
573
|
branding?: ConnectaBranding,
|
|
451
574
|
nonce?: string,
|
|
575
|
+
page: OperatorPage = "connections",
|
|
452
576
|
): string {
|
|
453
577
|
const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
|
|
454
578
|
// The Clerk loader's origin. A value that fails the gate is dropped rather
|
|
@@ -457,24 +581,31 @@ export function renderUiHtml(
|
|
|
457
581
|
// the same fallback-and-warn posture the branding URLs take, with the drop
|
|
458
582
|
// named in a startup warning (see `droppedUiAuthUrls`).
|
|
459
583
|
const clerkScriptOrigin =
|
|
460
|
-
clerk &&
|
|
584
|
+
clerk && isSafeHttpsUrl(clerk.frontendApiUrl)
|
|
461
585
|
? clerk.frontendApiUrl
|
|
462
586
|
: undefined;
|
|
463
587
|
// Enumerated field by field, because this object is serialized into the page's
|
|
464
588
|
// inline script: a rejected frontendApiUrl must not reach the document through
|
|
465
|
-
// `AUTH` after being kept out of the `<script src
|
|
589
|
+
// `AUTH` after being kept out of the `<script src>`, and a rejected
|
|
590
|
+
// signInUrl/signUpUrl — which `AUTH` is the only path into the page for — must
|
|
591
|
+
// not reach it at all. Dropping one leaves the key absent, so `Clerk.load`
|
|
592
|
+
// falls back to its own default the same way it does for an unset value.
|
|
466
593
|
const auth = clerk
|
|
467
594
|
? {
|
|
468
595
|
kind: clerk.kind,
|
|
469
596
|
publishableKey: clerk.publishableKey,
|
|
470
597
|
...(clerkScriptOrigin ? { frontendApiUrl: clerkScriptOrigin } : {}),
|
|
471
|
-
...(clerk.signInUrl
|
|
472
|
-
|
|
598
|
+
...(isSafeHttpsUrl(clerk.signInUrl)
|
|
599
|
+
? { signInUrl: clerk.signInUrl }
|
|
600
|
+
: {}),
|
|
601
|
+
...(isSafeHttpsUrl(clerk.signUpUrl)
|
|
602
|
+
? { signUpUrl: clerk.signUpUrl }
|
|
603
|
+
: {}),
|
|
473
604
|
}
|
|
474
605
|
: (uiAuth ?? { kind: "bearer" as const });
|
|
475
606
|
const brand = resolveBranding(branding);
|
|
476
|
-
const title = brand.pageTitle;
|
|
477
|
-
// When
|
|
607
|
+
const title = operatorPageTitle(page, brand.pageTitle);
|
|
608
|
+
// When an operator shell ships a nonce-based CSP, every script it emits must
|
|
478
609
|
// carry that nonce to run; without a nonce the markup is unchanged.
|
|
479
610
|
const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
|
|
480
611
|
// Top-left corner. With an owner set it reads "<owner> <product>"; without
|
|
@@ -515,6 +646,7 @@ ${clerkScript}
|
|
|
515
646
|
--paper: #fff;
|
|
516
647
|
--rule: #ccc;
|
|
517
648
|
--muted: #666;
|
|
649
|
+
--trace: #f5f5f5;
|
|
518
650
|
--shell: 70rem;
|
|
519
651
|
--pad: 1rem;
|
|
520
652
|
--gap: 1.5rem;
|
|
@@ -561,6 +693,15 @@ ${clerkScript}
|
|
|
561
693
|
outline: 1px solid var(--ink);
|
|
562
694
|
}
|
|
563
695
|
:is(a, button, summary):focus-visible { outline-offset: 2px; }
|
|
696
|
+
.skip-link {
|
|
697
|
+
background: var(--paper);
|
|
698
|
+
left: var(--pad);
|
|
699
|
+
padding: .5rem;
|
|
700
|
+
position: fixed;
|
|
701
|
+
top: -4rem;
|
|
702
|
+
z-index: 10;
|
|
703
|
+
}
|
|
704
|
+
.skip-link:focus { top: var(--pad); }
|
|
564
705
|
|
|
565
706
|
.shell {
|
|
566
707
|
margin: 0 auto;
|
|
@@ -579,6 +720,15 @@ ${clerkScript}
|
|
|
579
720
|
.cap, .meta { color: var(--muted); font-size: .9em; }
|
|
580
721
|
.mono { font-family: var(--mono); font-size: .78rem; }
|
|
581
722
|
.hidden { display: none !important; }
|
|
723
|
+
.visually-hidden {
|
|
724
|
+
clip: rect(0 0 0 0);
|
|
725
|
+
clip-path: inset(50%);
|
|
726
|
+
height: 1px;
|
|
727
|
+
overflow: hidden;
|
|
728
|
+
position: absolute;
|
|
729
|
+
white-space: nowrap;
|
|
730
|
+
width: 1px;
|
|
731
|
+
}
|
|
582
732
|
|
|
583
733
|
.masthead {
|
|
584
734
|
align-items: start;
|
|
@@ -603,6 +753,17 @@ ${clerkScript}
|
|
|
603
753
|
justify-content: flex-end;
|
|
604
754
|
min-width: 0;
|
|
605
755
|
}
|
|
756
|
+
.page-nav,
|
|
757
|
+
.session-actions {
|
|
758
|
+
display: flex;
|
|
759
|
+
flex-wrap: wrap;
|
|
760
|
+
gap: .5rem var(--gap);
|
|
761
|
+
}
|
|
762
|
+
.mast-actions :is(a, button) {
|
|
763
|
+
align-items: center;
|
|
764
|
+
display: inline-flex;
|
|
765
|
+
min-height: 2rem;
|
|
766
|
+
}
|
|
606
767
|
.navlink,
|
|
607
768
|
.linklike {
|
|
608
769
|
text-decoration: underline;
|
|
@@ -612,7 +773,7 @@ ${clerkScript}
|
|
|
612
773
|
.navlink { text-decoration-color: transparent; }
|
|
613
774
|
.navlink:hover,
|
|
614
775
|
.navlink:focus-visible,
|
|
615
|
-
.navlink
|
|
776
|
+
.navlink[aria-current="page"] { text-decoration-color: currentColor; }
|
|
616
777
|
.linklike { text-decoration-color: currentColor; }
|
|
617
778
|
.linklike:hover,
|
|
618
779
|
.linklike:focus-visible { text-decoration-color: transparent; }
|
|
@@ -660,7 +821,24 @@ ${clerkScript}
|
|
|
660
821
|
#notice:empty { display: none; }
|
|
661
822
|
#notice:not(:empty) { text-decoration: underline; }
|
|
662
823
|
.error-notice, .msg { text-decoration: underline; }
|
|
663
|
-
.card
|
|
824
|
+
.card,
|
|
825
|
+
.credential-card,
|
|
826
|
+
.activity-item {
|
|
827
|
+
padding-left: 1.25rem;
|
|
828
|
+
position: relative;
|
|
829
|
+
}
|
|
830
|
+
.card::before,
|
|
831
|
+
.credential-card::before,
|
|
832
|
+
.activity-item::before {
|
|
833
|
+
background: var(--rule);
|
|
834
|
+
bottom: 0;
|
|
835
|
+
content: "";
|
|
836
|
+
left: .25rem;
|
|
837
|
+
position: absolute;
|
|
838
|
+
top: 0;
|
|
839
|
+
width: 1px;
|
|
840
|
+
}
|
|
841
|
+
.card { border-top: 1px solid var(--rule); padding-bottom: .75rem; padding-top: .75rem; }
|
|
664
842
|
.connector-head {
|
|
665
843
|
display: grid;
|
|
666
844
|
gap: var(--gap);
|
|
@@ -671,14 +849,25 @@ ${clerkScript}
|
|
|
671
849
|
display: flex;
|
|
672
850
|
gap: .5rem;
|
|
673
851
|
}
|
|
852
|
+
.connector-title .dot,
|
|
853
|
+
.activity-stamp .dot {
|
|
854
|
+
margin-left: -1.25rem;
|
|
855
|
+
}
|
|
856
|
+
.activity-stamp {
|
|
857
|
+
align-items: baseline;
|
|
858
|
+
display: flex;
|
|
859
|
+
gap: .75rem;
|
|
860
|
+
}
|
|
674
861
|
.card h2 { overflow-wrap: anywhere; }
|
|
675
862
|
.connector-state { text-align: right; }
|
|
676
863
|
.dot {
|
|
864
|
+
background: var(--paper);
|
|
677
865
|
border: 1px solid var(--ink);
|
|
678
866
|
display: inline-block;
|
|
679
867
|
flex: none;
|
|
680
868
|
height: .5rem;
|
|
681
869
|
width: .5rem;
|
|
870
|
+
z-index: 1;
|
|
682
871
|
}
|
|
683
872
|
.dot.ok { background: var(--ink); }
|
|
684
873
|
.dot.auth_required {
|
|
@@ -688,7 +877,8 @@ ${clerkScript}
|
|
|
688
877
|
.connector-message,
|
|
689
878
|
.connector-auth { margin-top: .75rem; }
|
|
690
879
|
|
|
691
|
-
.credential { border-
|
|
880
|
+
.credential-ledger { border-bottom: 1px solid var(--rule); }
|
|
881
|
+
.credential-card { border-top: 1px solid var(--rule); padding-bottom: .75rem; padding-top: .75rem; }
|
|
692
882
|
.credential-head {
|
|
693
883
|
align-items: baseline;
|
|
694
884
|
display: flex;
|
|
@@ -697,7 +887,27 @@ ${clerkScript}
|
|
|
697
887
|
justify-content: space-between;
|
|
698
888
|
}
|
|
699
889
|
.credential-copy { margin-top: .25rem; max-width: 40rem; }
|
|
890
|
+
.credential-field-summary {
|
|
891
|
+
border-top: 1px solid var(--rule);
|
|
892
|
+
margin-top: .75rem;
|
|
893
|
+
}
|
|
894
|
+
.credential-field-summary > div {
|
|
895
|
+
border-bottom: 1px solid var(--rule);
|
|
896
|
+
display: flex;
|
|
897
|
+
flex-wrap: wrap;
|
|
898
|
+
gap: .25rem var(--gap);
|
|
899
|
+
justify-content: space-between;
|
|
900
|
+
padding: .5rem 0;
|
|
901
|
+
}
|
|
700
902
|
.credential-actions { display: flex; flex-wrap: wrap; gap: var(--gap); margin-top: .75rem; }
|
|
903
|
+
.credential-actions button,
|
|
904
|
+
.credential-form button,
|
|
905
|
+
.activity-controls button,
|
|
906
|
+
.activity-more {
|
|
907
|
+
align-items: center;
|
|
908
|
+
display: inline-flex;
|
|
909
|
+
min-height: 2.75rem;
|
|
910
|
+
}
|
|
701
911
|
.credential-form {
|
|
702
912
|
align-items: center;
|
|
703
913
|
display: flex;
|
|
@@ -741,7 +951,8 @@ ${clerkScript}
|
|
|
741
951
|
display: grid;
|
|
742
952
|
gap: .25rem var(--gap);
|
|
743
953
|
grid-template-columns: minmax(9rem, .85fr) minmax(12rem, 1.4fr) minmax(8rem, .9fr);
|
|
744
|
-
padding: .75rem
|
|
954
|
+
padding-bottom: .75rem;
|
|
955
|
+
padding-top: .75rem;
|
|
745
956
|
}
|
|
746
957
|
.activity-time,
|
|
747
958
|
.activity-actor,
|
|
@@ -752,6 +963,16 @@ ${clerkScript}
|
|
|
752
963
|
.activity-item.timeout .activity-outcome { text-decoration: underline; }
|
|
753
964
|
.activity-empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
|
|
754
965
|
.activity-more { margin-top: .75rem; }
|
|
966
|
+
.unavailable {
|
|
967
|
+
background: var(--trace);
|
|
968
|
+
border-bottom: 1px solid var(--rule);
|
|
969
|
+
border-top: 1px solid var(--rule);
|
|
970
|
+
padding: .75rem;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
@media (prefers-reduced-motion: reduce) {
|
|
974
|
+
html:focus-within { scroll-behavior: auto; }
|
|
975
|
+
}
|
|
755
976
|
|
|
756
977
|
@media (max-width: 36.99rem) {
|
|
757
978
|
.pgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
@@ -760,7 +981,12 @@ ${clerkScript}
|
|
|
760
981
|
.masthead .brand { grid-column: 1; }
|
|
761
982
|
.mast-nav { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
|
|
762
983
|
.product { display: none; }
|
|
763
|
-
.mast-actions {
|
|
984
|
+
.mast-actions {
|
|
985
|
+
align-items: flex-start;
|
|
986
|
+
flex-direction: column;
|
|
987
|
+
font-size: .875rem;
|
|
988
|
+
gap: .25rem;
|
|
989
|
+
}
|
|
764
990
|
.lead { margin-top: 4rem; }
|
|
765
991
|
.section,
|
|
766
992
|
.section + .section { margin-top: 2.5rem; }
|
|
@@ -769,49 +995,58 @@ ${clerkScript}
|
|
|
769
995
|
.activity-item { grid-template-columns: 1fr; }
|
|
770
996
|
.connector-state { text-align: left; }
|
|
771
997
|
.credential-field { align-items: start; grid-template-columns: 1fr; gap: .25rem; }
|
|
998
|
+
input { min-height: 2.75rem; }
|
|
772
999
|
}
|
|
773
1000
|
</style>
|
|
774
1001
|
</head>
|
|
775
1002
|
<body>
|
|
1003
|
+
<a class="skip-link" href="#operatorContent">Skip to operator page</a>
|
|
776
1004
|
<header class="masthead shell pgrid">
|
|
777
1005
|
${owner}
|
|
778
1006
|
<div class="mast-nav">
|
|
779
1007
|
${product}
|
|
780
|
-
<
|
|
781
|
-
<
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1008
|
+
<div id="appNav" class="mast-actions hidden">
|
|
1009
|
+
<nav class="page-nav" aria-label="Operator pages">
|
|
1010
|
+
<a id="connectionsNav" class="navlink" href="/"
|
|
1011
|
+
data-operator-page="connections"${page === "connections" ? ' aria-current="page"' : ""}>Connections</a>
|
|
1012
|
+
<a id="credentialsNav" class="navlink hidden" href="/credentials"
|
|
1013
|
+
data-operator-page="credentials"${page === "credentials" ? ' aria-current="page"' : ""}>Credentials</a>
|
|
1014
|
+
<a id="activityNav" class="navlink hidden" href="/activity"
|
|
1015
|
+
data-operator-page="activity"${page === "activity" ? ' aria-current="page"' : ""}>Activity</a>
|
|
1016
|
+
</nav>
|
|
1017
|
+
<div class="session-actions" aria-label="Session actions">
|
|
1018
|
+
<button id="change" class="navlink hidden" type="button">Change token</button>
|
|
1019
|
+
<button id="signout" class="navlink hidden" type="button">Sign out</button>
|
|
1020
|
+
</div>
|
|
1021
|
+
</div>
|
|
788
1022
|
</div>
|
|
789
1023
|
</header>
|
|
790
1024
|
|
|
791
|
-
<main id="
|
|
792
|
-
<section
|
|
793
|
-
<
|
|
794
|
-
|
|
795
|
-
<
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
<
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
<
|
|
804
|
-
|
|
1025
|
+
<main id="operatorContent" class="page shell" tabindex="-1">
|
|
1026
|
+
<section id="gate" class="hidden">
|
|
1027
|
+
<div class="lead pgrid">
|
|
1028
|
+
<h1 id="gateHeading" class="pcap" tabindex="-1">${OPERATOR_PAGE_LABELS[page]}</h1>
|
|
1029
|
+
<div class="pbody lead-copy">
|
|
1030
|
+
<p>${escapeHtmlAttr(brand.description)}</p>
|
|
1031
|
+
<p id="gateCopy" class="meta"></p>
|
|
1032
|
+
<div id="tokenGate" class="row gate-actions hidden">
|
|
1033
|
+
<input id="token" type="password" placeholder="Bearer token" autocomplete="off"
|
|
1034
|
+
aria-label="Bearer token">
|
|
1035
|
+
<button id="save" class="linklike" type="button">Open operator pages</button>
|
|
1036
|
+
</div>
|
|
1037
|
+
<div id="clerkGate" class="actions gate-actions hidden">
|
|
1038
|
+
<button id="signin" class="linklike" type="button">Team sign in</button>
|
|
1039
|
+
<button id="gateSignout" class="linklike hidden" type="button">Sign out</button>
|
|
1040
|
+
</div>
|
|
1041
|
+
<p id="err" role="alert"></p>
|
|
805
1042
|
</div>
|
|
806
|
-
<p id="err"></p>
|
|
807
1043
|
</div>
|
|
808
1044
|
</section>
|
|
809
|
-
</main>
|
|
810
1045
|
|
|
811
|
-
<
|
|
812
|
-
<section id="
|
|
1046
|
+
<div id="app" class="hidden">
|
|
1047
|
+
<section id="connectionsView"${page === "connections" ? "" : ' class="hidden"'}>
|
|
813
1048
|
<div class="lead pgrid">
|
|
814
|
-
<h1 class="pcap">
|
|
1049
|
+
<h1 id="connectionsHeading" class="pcap" tabindex="-1">Connections</h1>
|
|
815
1050
|
<div class="pbody lead-copy">
|
|
816
1051
|
<p>Use this endpoint to give an MCP client access to the tools below.</p>
|
|
817
1052
|
<div class="endpoint">
|
|
@@ -820,50 +1055,80 @@ ${clerkScript}
|
|
|
820
1055
|
<button id="copyMcpUrl" class="linklike" type="button">Copy URL</button>
|
|
821
1056
|
</div>
|
|
822
1057
|
</div>
|
|
823
|
-
<p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)}
|
|
1058
|
+
<p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} operator</p>
|
|
824
1059
|
</div>
|
|
825
1060
|
</div>
|
|
826
|
-
<section class="section pgrid" aria-labelledby="
|
|
827
|
-
<h2 class="pcap" id="
|
|
1061
|
+
<section class="section pgrid" aria-labelledby="connectorLedgerHeading">
|
|
1062
|
+
<h2 class="pcap" id="connectorLedgerHeading">Connectors</h2>
|
|
828
1063
|
<div class="pbody">
|
|
829
1064
|
<div class="row toolbar">
|
|
830
1065
|
<input id="filter" type="search" placeholder="Filter connectors or tools…"
|
|
831
1066
|
aria-label="Filter connectors or tools">
|
|
832
1067
|
</div>
|
|
833
|
-
<
|
|
834
|
-
<div id="list" class="connector-tools"></div>
|
|
1068
|
+
<div id="list" class="connector-tools" aria-busy="false"></div>
|
|
835
1069
|
</div>
|
|
836
1070
|
</section>
|
|
837
1071
|
</section>
|
|
838
|
-
|
|
1072
|
+
|
|
1073
|
+
<section id="credentialsView"${page === "credentials" ? "" : ' class="hidden"'}>
|
|
1074
|
+
<div class="lead pgrid">
|
|
1075
|
+
<h1 id="credentialsHeading" class="pcap" tabindex="-1">Credentials</h1>
|
|
1076
|
+
<div class="pbody">
|
|
1077
|
+
<p class="activity-copy">Rotate operator-managed connector credentials. Stored values are never returned or displayed.</p>
|
|
1078
|
+
<p id="credentialNotice" class="meta" role="status" aria-live="polite"
|
|
1079
|
+
tabindex="-1"></p>
|
|
1080
|
+
<div id="credentialUnavailable" class="unavailable hidden"></div>
|
|
1081
|
+
<div id="credentialList" class="credential-ledger" aria-busy="false"></div>
|
|
1082
|
+
</div>
|
|
1083
|
+
</div>
|
|
1084
|
+
</section>
|
|
1085
|
+
|
|
1086
|
+
<section id="activityView"${page === "activity" ? "" : ' class="hidden"'}>
|
|
839
1087
|
<div class="lead pgrid">
|
|
840
|
-
<h1 class="pcap">
|
|
1088
|
+
<h1 id="activityHeading" class="pcap" tabindex="-1">Activity</h1>
|
|
841
1089
|
<div class="pbody">
|
|
842
1090
|
<p class="activity-copy" id="activitySummary">Arguments and results are never stored.</p>
|
|
843
|
-
<div class="
|
|
844
|
-
<
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1091
|
+
<div id="activityUnavailable" class="unavailable hidden">
|
|
1092
|
+
Activity history is not configured. Add an <span class="mono">activity.store</span>
|
|
1093
|
+
with a list reader to enable this page.
|
|
1094
|
+
</div>
|
|
1095
|
+
<div id="activityAvailable">
|
|
1096
|
+
<div class="row activity-controls">
|
|
1097
|
+
<input id="activitySearch" type="search"
|
|
1098
|
+
placeholder="Search user, tool, or outcome…"
|
|
1099
|
+
aria-label="Search loaded activity">
|
|
1100
|
+
<button id="refreshActivity" class="linklike" type="button">Refresh</button>
|
|
1101
|
+
</div>
|
|
1102
|
+
<p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
|
|
1103
|
+
<div id="activityList" class="activity-ledger" aria-busy="false"></div>
|
|
1104
|
+
<button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
|
|
848
1105
|
</div>
|
|
849
|
-
<p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
|
|
850
|
-
<div id="activityList" class="activity-ledger"></div>
|
|
851
|
-
<button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
|
|
852
1106
|
</div>
|
|
853
1107
|
</div>
|
|
854
1108
|
</section>
|
|
1109
|
+
</div>
|
|
855
1110
|
</main>
|
|
856
1111
|
|
|
857
1112
|
<script${nonceAttr}>
|
|
858
1113
|
const AUTH = ${jsonForInlineScript(auth)};
|
|
859
1114
|
const MCP_URL = ${jsonForInlineScript(mcpUrl)};
|
|
1115
|
+
const INITIAL_PAGE = ${jsonForInlineScript(page)};
|
|
1116
|
+
const TITLE_SUFFIX = ${jsonForInlineScript(brand.pageTitle)};
|
|
860
1117
|
const filterUiConnectors = ${filterUiConnectors.toString()};
|
|
861
1118
|
const KEY = "connecta:token";
|
|
862
1119
|
const $ = (id) => document.getElementById(id);
|
|
1120
|
+
const PAGE_META = {
|
|
1121
|
+
connections: { path: "/", label: "Connections" },
|
|
1122
|
+
credentials: { path: "/credentials", label: "Credentials" },
|
|
1123
|
+
activity: { path: "/activity", label: "Activity" },
|
|
1124
|
+
};
|
|
863
1125
|
let DATA = null;
|
|
864
1126
|
let ACTIVITY = [];
|
|
865
1127
|
let ACTIVITY_CURSOR = null;
|
|
866
1128
|
let ACTIVITY_LOADED = false;
|
|
1129
|
+
let CURRENT_PAGE = INITIAL_PAGE;
|
|
1130
|
+
let SESSION_GENERATION = 0;
|
|
1131
|
+
let ACTIVITY_GENERATION = 0;
|
|
867
1132
|
$("mcpUrl").textContent = MCP_URL;
|
|
868
1133
|
|
|
869
1134
|
function esc(s) {
|
|
@@ -887,8 +1152,9 @@ function formatDate(value) {
|
|
|
887
1152
|
}
|
|
888
1153
|
|
|
889
1154
|
function setNotice(message, isError) {
|
|
890
|
-
$("
|
|
891
|
-
$("
|
|
1155
|
+
$("credentialNotice").textContent = message || "";
|
|
1156
|
+
$("credentialNotice").classList.toggle("error-notice", Boolean(isError));
|
|
1157
|
+
$("credentialNotice").setAttribute("role", isError ? "alert" : "status");
|
|
892
1158
|
}
|
|
893
1159
|
|
|
894
1160
|
async function sessionToken() {
|
|
@@ -897,7 +1163,45 @@ async function sessionToken() {
|
|
|
897
1163
|
: localStorage.getItem(KEY);
|
|
898
1164
|
}
|
|
899
1165
|
|
|
1166
|
+
function clearActivityState() {
|
|
1167
|
+
ACTIVITY_GENERATION += 1;
|
|
1168
|
+
ACTIVITY = [];
|
|
1169
|
+
ACTIVITY_CURSOR = null;
|
|
1170
|
+
ACTIVITY_LOADED = false;
|
|
1171
|
+
$("activityList").innerHTML = "";
|
|
1172
|
+
$("activityList").setAttribute("aria-busy", "false");
|
|
1173
|
+
$("activityNotice").textContent = "";
|
|
1174
|
+
$("activityNotice").setAttribute("role", "status");
|
|
1175
|
+
$("activitySummary").textContent = "Arguments and results are never stored.";
|
|
1176
|
+
$("activitySearch").value = "";
|
|
1177
|
+
$("refreshActivity").disabled = false;
|
|
1178
|
+
$("moreActivity").disabled = false;
|
|
1179
|
+
$("moreActivity").classList.add("hidden");
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function clearIdentityState() {
|
|
1183
|
+
SESSION_GENERATION += 1;
|
|
1184
|
+
DATA = null;
|
|
1185
|
+
clearActivityState();
|
|
1186
|
+
$("list").innerHTML = "";
|
|
1187
|
+
$("filter").value = "";
|
|
1188
|
+
$("credentialList").innerHTML = "";
|
|
1189
|
+
$("credentialList").setAttribute("aria-busy", "false");
|
|
1190
|
+
$("credentialNotice").textContent = "";
|
|
1191
|
+
$("credentialNotice").setAttribute("role", "status");
|
|
1192
|
+
$("credentialNotice").classList.remove("error-notice");
|
|
1193
|
+
$("credentialUnavailable").textContent = "";
|
|
1194
|
+
$("credentialUnavailable").classList.add("hidden");
|
|
1195
|
+
$("credentialList").classList.add("hidden");
|
|
1196
|
+
$("activityUnavailable").classList.add("hidden");
|
|
1197
|
+
$("activityAvailable").classList.add("hidden");
|
|
1198
|
+
$("credentialsNav").classList.add("hidden");
|
|
1199
|
+
$("activityNav").classList.add("hidden");
|
|
1200
|
+
$("serverInfo").textContent = ${escapeScriptString(brand.productName + " operator")};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
900
1203
|
function showGate(msg) {
|
|
1204
|
+
clearIdentityState();
|
|
901
1205
|
$("app").classList.add("hidden");
|
|
902
1206
|
$("appNav").classList.add("hidden");
|
|
903
1207
|
$("gate").classList.remove("hidden");
|
|
@@ -905,8 +1209,8 @@ function showGate(msg) {
|
|
|
905
1209
|
if (AUTH.kind === "clerk") {
|
|
906
1210
|
const signedIn = Boolean(window.Clerk && Clerk.user);
|
|
907
1211
|
$("gateCopy").textContent = signedIn
|
|
908
|
-
? "Signed in with Clerk, but this account cannot open
|
|
909
|
-
: "Sign in with Clerk to open
|
|
1212
|
+
? "Signed in with Clerk, but this account cannot open deployment-wide operator pages."
|
|
1213
|
+
: "Sign in with Clerk to open this operator page.";
|
|
910
1214
|
$("signin").classList.toggle("hidden", signedIn);
|
|
911
1215
|
$("gateSignout").classList.toggle("hidden", !signedIn);
|
|
912
1216
|
} else {
|
|
@@ -914,20 +1218,87 @@ function showGate(msg) {
|
|
|
914
1218
|
}
|
|
915
1219
|
}
|
|
916
1220
|
|
|
1221
|
+
function pageForPath(path) {
|
|
1222
|
+
if (path === "/credentials") return "credentials";
|
|
1223
|
+
if (path === "/activity") return "activity";
|
|
1224
|
+
return "connections";
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function credentialUnavailableCopy(capability) {
|
|
1228
|
+
if (capability === "no_slots") {
|
|
1229
|
+
return "No connectors declare operator-managed credential slots. Connector credentials remain configuration-as-code until a slot is declared.";
|
|
1230
|
+
}
|
|
1231
|
+
if (capability === "vault_not_configured") {
|
|
1232
|
+
return "Credential storage is not configured. Set credentials.encryptionKey before managing connector credentials here.";
|
|
1233
|
+
}
|
|
1234
|
+
return "Credential management requires an eligible Clerk operator. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials.";
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function updateCapabilities() {
|
|
1238
|
+
const credentialsAvailable =
|
|
1239
|
+
DATA?.credentialManagement === "available";
|
|
1240
|
+
$("credentialsNav").classList.toggle("hidden", !credentialsAvailable);
|
|
1241
|
+
$("activityNav").classList.toggle("hidden", !DATA?.activityEnabled);
|
|
1242
|
+
$("credentialUnavailable").classList.toggle("hidden", credentialsAvailable);
|
|
1243
|
+
$("credentialList").classList.toggle("hidden", !credentialsAvailable);
|
|
1244
|
+
$("credentialUnavailable").textContent = credentialsAvailable
|
|
1245
|
+
? ""
|
|
1246
|
+
: credentialUnavailableCopy(DATA?.credentialManagement);
|
|
1247
|
+
$("activityUnavailable").classList.toggle("hidden", Boolean(DATA?.activityEnabled));
|
|
1248
|
+
$("activityAvailable").classList.toggle("hidden", !DATA?.activityEnabled);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function activatePage(page, options) {
|
|
1252
|
+
const next = PAGE_META[page] ? page : "connections";
|
|
1253
|
+
CURRENT_PAGE = next;
|
|
1254
|
+
for (const name of Object.keys(PAGE_META)) {
|
|
1255
|
+
$(name + "View").classList.toggle("hidden", name !== next);
|
|
1256
|
+
const link = $(name + "Nav");
|
|
1257
|
+
if (name === next) link.setAttribute("aria-current", "page");
|
|
1258
|
+
else link.removeAttribute("aria-current");
|
|
1259
|
+
}
|
|
1260
|
+
$("gateHeading").textContent = PAGE_META[next].label;
|
|
1261
|
+
document.title = PAGE_META[next].label + " — " + TITLE_SUFFIX;
|
|
1262
|
+
if (DATA) {
|
|
1263
|
+
updateCapabilities();
|
|
1264
|
+
if (next === "connections") renderConnections();
|
|
1265
|
+
if (next === "credentials") renderCredentials();
|
|
1266
|
+
if (next === "activity" && DATA.activityEnabled && !ACTIVITY_LOADED) {
|
|
1267
|
+
loadActivity(true);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (next !== "credentials") $("credentialList").innerHTML = "";
|
|
1271
|
+
// Focus what is actually on screen. While gated the page views are hidden, so
|
|
1272
|
+
// focusing their heading is a silent no-op that drops focus to <body> and
|
|
1273
|
+
// restarts the next Tab from the top of the document — the gate's own h1 is
|
|
1274
|
+
// the visible heading, and activatePage has just relabelled it.
|
|
1275
|
+
if (options?.focus) $(DATA ? next + "Heading" : "gateHeading").focus();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function navigateTo(page, href) {
|
|
1279
|
+
history.pushState({ operatorPage: page }, "", href);
|
|
1280
|
+
activatePage(page, { focus: true });
|
|
1281
|
+
}
|
|
1282
|
+
|
|
917
1283
|
async function load() {
|
|
1284
|
+
const generation = SESSION_GENERATION;
|
|
918
1285
|
let token;
|
|
919
1286
|
try {
|
|
920
1287
|
token = await sessionToken();
|
|
921
1288
|
} catch (e) {
|
|
1289
|
+
if (generation !== SESSION_GENERATION) return;
|
|
922
1290
|
return showGate("Could not read the Clerk session: " + e.message);
|
|
923
1291
|
}
|
|
1292
|
+
if (generation !== SESSION_GENERATION) return;
|
|
924
1293
|
if (!token) return showGate("");
|
|
925
1294
|
let res;
|
|
926
1295
|
try {
|
|
927
1296
|
res = await fetch("/ui/data", { headers: { Authorization: "Bearer " + token } });
|
|
928
1297
|
} catch (e) {
|
|
1298
|
+
if (generation !== SESSION_GENERATION) return;
|
|
929
1299
|
return showGate("Network error: " + e.message);
|
|
930
1300
|
}
|
|
1301
|
+
if (generation !== SESSION_GENERATION) return;
|
|
931
1302
|
if (res.status === 401 || res.status === 403) {
|
|
932
1303
|
if (AUTH.kind === "clerk") {
|
|
933
1304
|
return showGate(
|
|
@@ -940,25 +1311,22 @@ async function load() {
|
|
|
940
1311
|
return showGate("Token rejected — enter a valid bearer token.");
|
|
941
1312
|
}
|
|
942
1313
|
if (!res.ok) return showGate("Error " + res.status);
|
|
943
|
-
|
|
1314
|
+
let data;
|
|
1315
|
+
try {
|
|
1316
|
+
data = await res.json();
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1319
|
+
return showGate("Operator data could not be read.");
|
|
1320
|
+
}
|
|
1321
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1322
|
+
DATA = data;
|
|
944
1323
|
$("gate").classList.add("hidden");
|
|
945
1324
|
$("app").classList.remove("hidden");
|
|
946
1325
|
$("appNav").classList.remove("hidden");
|
|
947
1326
|
const si = DATA.serverInfo || {};
|
|
948
1327
|
$("serverInfo").textContent = (si.name || ${escapeScriptString(brand.productName)}) + " v" + (si.version || "?");
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
function showView(view) {
|
|
954
|
-
const activity = view === "activity";
|
|
955
|
-
$("configView").classList.toggle("hidden", activity);
|
|
956
|
-
$("activityView").classList.toggle("hidden", !activity);
|
|
957
|
-
$("configTab").classList.toggle("active", !activity);
|
|
958
|
-
$("activityTab").classList.toggle("active", activity);
|
|
959
|
-
$("configTab").setAttribute("aria-pressed", String(!activity));
|
|
960
|
-
$("activityTab").setAttribute("aria-pressed", String(activity));
|
|
961
|
-
if (activity && !ACTIVITY_LOADED) loadActivity(true);
|
|
1328
|
+
updateCapabilities();
|
|
1329
|
+
activatePage(CURRENT_PAGE);
|
|
962
1330
|
}
|
|
963
1331
|
|
|
964
1332
|
function actorLabel(actor) {
|
|
@@ -997,12 +1365,21 @@ function renderActivity() {
|
|
|
997
1365
|
}
|
|
998
1366
|
for (const event of visible) {
|
|
999
1367
|
const item = document.createElement("article");
|
|
1000
|
-
|
|
1001
|
-
|
|
1368
|
+
const outcomeClass = ["success", "error", "timeout"].includes(event.outcome)
|
|
1369
|
+
? event.outcome
|
|
1370
|
+
: "error";
|
|
1371
|
+
item.className = "activity-item " + outcomeClass;
|
|
1372
|
+
const retryCopy = event.attempts > 1
|
|
1373
|
+
? " · " + esc(event.attempts) + " attempts"
|
|
1374
|
+
: "";
|
|
1002
1375
|
const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
|
|
1003
1376
|
item.innerHTML =
|
|
1004
|
-
'<div
|
|
1005
|
-
|
|
1377
|
+
'<div class="activity-stamp"><span class="dot ' +
|
|
1378
|
+
(outcomeClass === "success" ? "ok" : "") +
|
|
1379
|
+
'" aria-hidden="true"></span><div><time class="activity-time" datetime="' +
|
|
1380
|
+
esc(event.occurredAt) + '">' +
|
|
1381
|
+
esc(formatDate(event.occurredAt)) +
|
|
1382
|
+
'</time><div class="activity-actor">' + esc(actorLabel(event.actor)) + '</div></div></div>' +
|
|
1006
1383
|
'<div><div class="activity-address">' + esc(event.address) +
|
|
1007
1384
|
'</div><div class="activity-detail">' + esc(event.source) + retryCopy +
|
|
1008
1385
|
errorCopy + '</div></div>' +
|
|
@@ -1015,22 +1392,45 @@ function renderActivity() {
|
|
|
1015
1392
|
|
|
1016
1393
|
async function loadActivity(reset) {
|
|
1017
1394
|
if (!DATA?.activityEnabled) return;
|
|
1395
|
+
const sessionGeneration = SESSION_GENERATION;
|
|
1396
|
+
if (reset) ACTIVITY_GENERATION += 1;
|
|
1397
|
+
const activityGeneration = ACTIVITY_GENERATION;
|
|
1398
|
+
const isCurrent = () =>
|
|
1399
|
+
sessionGeneration === SESSION_GENERATION &&
|
|
1400
|
+
activityGeneration === ACTIVITY_GENERATION;
|
|
1018
1401
|
$("activityNotice").textContent = "Loading activity…";
|
|
1402
|
+
$("activityNotice").setAttribute("role", "status");
|
|
1403
|
+
$("activityList").setAttribute("aria-busy", "true");
|
|
1019
1404
|
$("refreshActivity").disabled = true;
|
|
1020
1405
|
$("moreActivity").disabled = true;
|
|
1021
1406
|
try {
|
|
1022
1407
|
const token = await sessionToken();
|
|
1023
|
-
if (!
|
|
1408
|
+
if (!isCurrent()) return;
|
|
1409
|
+
if (!token) return showGate("Your session has expired.");
|
|
1024
1410
|
const cursor = reset ? null : ACTIVITY_CURSOR;
|
|
1025
1411
|
const params = new URLSearchParams({ limit: "50" });
|
|
1026
1412
|
if (cursor) params.set("cursor", cursor);
|
|
1027
1413
|
const res = await fetch("/ui/activity?" + params, {
|
|
1028
1414
|
headers: { Authorization: "Bearer " + token },
|
|
1029
1415
|
});
|
|
1416
|
+
if (!isCurrent()) return;
|
|
1030
1417
|
let payload = {};
|
|
1031
1418
|
try { payload = await res.json(); } catch (e) {}
|
|
1419
|
+
if (!isCurrent()) return;
|
|
1420
|
+
if (res.status === 401) {
|
|
1421
|
+
return showGate("Your session was not accepted. Sign in again.");
|
|
1422
|
+
}
|
|
1423
|
+
if (res.status === 403) {
|
|
1424
|
+
clearActivityState();
|
|
1425
|
+
$("activityNotice").setAttribute("role", "alert");
|
|
1426
|
+
$("activityNotice").textContent =
|
|
1427
|
+
"This identity may not read activity history.";
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1032
1430
|
if (!res.ok) {
|
|
1033
|
-
throw new Error(
|
|
1431
|
+
throw new Error(
|
|
1432
|
+
payload.error || "Activity could not be loaded (" + res.status + ")."
|
|
1433
|
+
);
|
|
1034
1434
|
}
|
|
1035
1435
|
ACTIVITY = reset
|
|
1036
1436
|
? (payload.events || [])
|
|
@@ -1040,14 +1440,19 @@ async function loadActivity(reset) {
|
|
|
1040
1440
|
$("activityNotice").textContent = "";
|
|
1041
1441
|
renderActivity();
|
|
1042
1442
|
} catch (e) {
|
|
1443
|
+
if (!isCurrent()) return;
|
|
1444
|
+
$("activityNotice").setAttribute("role", "alert");
|
|
1043
1445
|
$("activityNotice").textContent = e.message || "Activity could not be loaded.";
|
|
1044
1446
|
} finally {
|
|
1045
|
-
|
|
1046
|
-
|
|
1447
|
+
if (isCurrent()) {
|
|
1448
|
+
$("activityList").setAttribute("aria-busy", "false");
|
|
1449
|
+
$("refreshActivity").disabled = false;
|
|
1450
|
+
$("moreActivity").disabled = false;
|
|
1451
|
+
}
|
|
1047
1452
|
}
|
|
1048
1453
|
}
|
|
1049
1454
|
|
|
1050
|
-
function
|
|
1455
|
+
function renderConnections() {
|
|
1051
1456
|
const q = $("filter").value.trim().toLowerCase();
|
|
1052
1457
|
const list = $("list");
|
|
1053
1458
|
list.innerHTML = "";
|
|
@@ -1095,66 +1500,8 @@ function render() {
|
|
|
1095
1500
|
esc(c.authorizationUrl) + "</p>";
|
|
1096
1501
|
}
|
|
1097
1502
|
if (c.credential) {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const removable = configured || Boolean(cred.removable);
|
|
1101
|
-
const state = configured
|
|
1102
|
-
? "configured · ••••" + esc(cred.lastFour || "")
|
|
1103
|
-
: "not configured";
|
|
1104
|
-
const updated = configured && cred.updatedAt
|
|
1105
|
-
? " · updated " + esc(formatDate(cred.updatedAt))
|
|
1106
|
-
: "";
|
|
1107
|
-
head += '<section class="credential" aria-label="' + esc(cred.label) + '">';
|
|
1108
|
-
head += '<div class="credential-head"><span class="credential-label">' +
|
|
1109
|
-
esc(cred.label) + '</span><span class="credential-state">' + state +
|
|
1110
|
-
updated + "</span></div>";
|
|
1111
|
-
if (cred.description) {
|
|
1112
|
-
head += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
|
|
1113
|
-
}
|
|
1114
|
-
if (cred.error) {
|
|
1115
|
-
head += '<div class="msg">' + esc(cred.error) + "</div>";
|
|
1116
|
-
}
|
|
1117
|
-
if (AUTH.kind === "clerk") {
|
|
1118
|
-
head += '<div class="credential-actions">';
|
|
1119
|
-
head += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
|
|
1120
|
-
esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
|
|
1121
|
-
if (configured && cred.testable) {
|
|
1122
|
-
head += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
|
|
1123
|
-
esc(c.id) + '">Test</button>';
|
|
1124
|
-
}
|
|
1125
|
-
if (removable) {
|
|
1126
|
-
head += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
|
|
1127
|
-
esc(c.id) + '">Remove</button>';
|
|
1128
|
-
}
|
|
1129
|
-
head += "</div>";
|
|
1130
|
-
head += '<div class="credential-form hidden" data-credential-form="' +
|
|
1131
|
-
esc(c.id) + '">';
|
|
1132
|
-
if (cred.fields && cred.fields.length) {
|
|
1133
|
-
head += '<div class="credential-fields">';
|
|
1134
|
-
for (const field of cred.fields) {
|
|
1135
|
-
head += '<div class="credential-field"><label>' +
|
|
1136
|
-
esc(field.label) + '</label><input type="' +
|
|
1137
|
-
esc(field.inputType || "password") + '" data-credential-field="' +
|
|
1138
|
-
esc(field.name) + '" aria-label="' + esc(field.label) +
|
|
1139
|
-
'" placeholder="' + esc(field.placeholder || field.label) +
|
|
1140
|
-
'" autocomplete="' +
|
|
1141
|
-
(field.inputType === "password" ? "new-password" : "off") +
|
|
1142
|
-
'" autocapitalize="none" spellcheck="false"></div>';
|
|
1143
|
-
}
|
|
1144
|
-
head += "</div>";
|
|
1145
|
-
} else {
|
|
1146
|
-
head += '<input type="password" data-credential-input="' +
|
|
1147
|
-
esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
|
|
1148
|
-
esc(cred.placeholder || "Paste credential") +
|
|
1149
|
-
'" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
|
|
1150
|
-
}
|
|
1151
|
-
head += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
|
|
1152
|
-
esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
|
|
1153
|
-
esc(c.id) + '">Cancel</button></div>';
|
|
1154
|
-
} else {
|
|
1155
|
-
head += '<p class="credential-copy meta">Team sign in is required to manage this credential.</p>';
|
|
1156
|
-
}
|
|
1157
|
-
head += "</section>";
|
|
1503
|
+
head += '<p class="connector-auth"><a class="linklike" href="/credentials" ' +
|
|
1504
|
+
'data-operator-page="credentials">Manage credential →</a></p>';
|
|
1158
1505
|
}
|
|
1159
1506
|
let body = "";
|
|
1160
1507
|
if (tools.length) {
|
|
@@ -1172,12 +1519,122 @@ function render() {
|
|
|
1172
1519
|
list.appendChild(el);
|
|
1173
1520
|
}
|
|
1174
1521
|
if (!list.children.length) {
|
|
1175
|
-
list.innerHTML = '<p class="empty">
|
|
1522
|
+
list.innerHTML = '<p class="empty">' +
|
|
1523
|
+
(q
|
|
1524
|
+
? "No connectors or tools match this filter."
|
|
1525
|
+
: "No connectors are declared in this deployment.") +
|
|
1526
|
+
"</p>";
|
|
1176
1527
|
}
|
|
1177
1528
|
}
|
|
1178
1529
|
|
|
1179
|
-
|
|
1530
|
+
function renderCredentials() {
|
|
1531
|
+
const list = $("credentialList");
|
|
1532
|
+
list.innerHTML = "";
|
|
1533
|
+
if (DATA.credentialManagement !== "available") return;
|
|
1534
|
+
for (const c of DATA.connectors.filter((connector) => connector.credential)) {
|
|
1535
|
+
const cred = c.credential;
|
|
1536
|
+
const configured = Boolean(cred.configured);
|
|
1537
|
+
const removable = configured || Boolean(cred.removable);
|
|
1538
|
+
const state = configured
|
|
1539
|
+
? cred.fields?.length
|
|
1540
|
+
? "configured"
|
|
1541
|
+
: "configured · ••••" + esc(cred.lastFour || "")
|
|
1542
|
+
: "not configured";
|
|
1543
|
+
const updated = configured && cred.updatedAt
|
|
1544
|
+
? " · updated " + esc(formatDate(cred.updatedAt))
|
|
1545
|
+
: "";
|
|
1546
|
+
const el = document.createElement("section");
|
|
1547
|
+
el.className = "credential-card";
|
|
1548
|
+
el.id = "credential-" + c.id;
|
|
1549
|
+
el.setAttribute("aria-labelledby", "credential-title-" + c.id);
|
|
1550
|
+
let body = '<div class="credential-head"><div class="connector-title">' +
|
|
1551
|
+
'<span class="dot ' + (configured ? "ok" : "auth_required") +
|
|
1552
|
+
'" aria-hidden="true"></span><h2 id="credential-title-' + esc(c.id) + '">' +
|
|
1553
|
+
esc(c.title || c.id) + '</h2></div><span class="credential-state">' +
|
|
1554
|
+
state + updated + "</span></div>";
|
|
1555
|
+
body += '<p class="mono">' + esc(c.id) + " · " + esc(cred.label) + "</p>";
|
|
1556
|
+
if (cred.description) {
|
|
1557
|
+
body += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
|
|
1558
|
+
}
|
|
1559
|
+
if (cred.fields?.length) {
|
|
1560
|
+
body += '<div class="credential-field-summary">';
|
|
1561
|
+
for (const field of cred.fields) {
|
|
1562
|
+
const fieldState = field.configured
|
|
1563
|
+
? "configured · ••••" + esc(field.lastFour || "") +
|
|
1564
|
+
(field.updatedAt ? " · updated " + esc(formatDate(field.updatedAt)) : "")
|
|
1565
|
+
: "not configured";
|
|
1566
|
+
body += '<div><span>' + esc(field.label) +
|
|
1567
|
+
'</span><span class="meta">' + fieldState + "</span></div>";
|
|
1568
|
+
}
|
|
1569
|
+
body += "</div>";
|
|
1570
|
+
}
|
|
1571
|
+
if (c.credentialCheck) {
|
|
1572
|
+
const check = c.credentialCheck;
|
|
1573
|
+
const verdict = check.state === "ok"
|
|
1574
|
+
? "healthy"
|
|
1575
|
+
: check.state === "auth_required"
|
|
1576
|
+
? "needs authorization"
|
|
1577
|
+
: "check failed";
|
|
1578
|
+
body += '<p class="connector-check meta">Liveness: ' + esc(verdict) +
|
|
1579
|
+
" · " + esc(formatDate(check.checkedAt)) +
|
|
1580
|
+
(check.message ? " — " + esc(check.message) : "") + "</p>";
|
|
1581
|
+
}
|
|
1582
|
+
if (cred.error) body += '<div class="msg">' + esc(cred.error) + "</div>";
|
|
1583
|
+
// Leftover stored fields are not an error — the credential still works, so
|
|
1584
|
+
// this stays muted copy rather than the msg block an actual failure earns.
|
|
1585
|
+
if (cred.notice) {
|
|
1586
|
+
body += '<p class="credential-copy meta">' + esc(cred.notice) + "</p>";
|
|
1587
|
+
}
|
|
1588
|
+
body += '<div class="credential-actions">';
|
|
1589
|
+
body += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
|
|
1590
|
+
esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
|
|
1591
|
+
if (configured && cred.testable) {
|
|
1592
|
+
body += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
|
|
1593
|
+
esc(c.id) + '">Test</button>';
|
|
1594
|
+
}
|
|
1595
|
+
if (removable) {
|
|
1596
|
+
body += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
|
|
1597
|
+
esc(c.id) + '">Remove</button>';
|
|
1598
|
+
}
|
|
1599
|
+
body += "</div>";
|
|
1600
|
+
body += '<div class="credential-form hidden" data-credential-form="' +
|
|
1601
|
+
esc(c.id) + '">';
|
|
1602
|
+
if (cred.fields && cred.fields.length) {
|
|
1603
|
+
body += '<div class="credential-fields">';
|
|
1604
|
+
for (let index = 0; index < cred.fields.length; index += 1) {
|
|
1605
|
+
const field = cred.fields[index];
|
|
1606
|
+
const inputId = "credential-input-" + c.id + "-" + index;
|
|
1607
|
+
body += '<div class="credential-field"><label for="' + esc(inputId) + '">' +
|
|
1608
|
+
esc(field.label) + '</label><input id="' + esc(inputId) + '" type="' +
|
|
1609
|
+
esc(field.inputType || "password") + '" data-credential-field="' +
|
|
1610
|
+
esc(field.name) + '" placeholder="' + esc(field.placeholder || field.label) +
|
|
1611
|
+
'" autocomplete="' +
|
|
1612
|
+
(field.inputType === "password" ? "new-password" : "off") +
|
|
1613
|
+
'" autocapitalize="none" spellcheck="false"></div>';
|
|
1614
|
+
}
|
|
1615
|
+
body += "</div>";
|
|
1616
|
+
} else {
|
|
1617
|
+
const inputId = "credential-input-" + c.id;
|
|
1618
|
+
body += '<label class="visually-hidden" for="' + esc(inputId) + '">' +
|
|
1619
|
+
esc(cred.label) + '</label><input id="' + esc(inputId) +
|
|
1620
|
+
'" type="password" data-credential-input="' +
|
|
1621
|
+
esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
|
|
1622
|
+
esc(cred.placeholder || "Paste credential") +
|
|
1623
|
+
'" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
|
|
1624
|
+
}
|
|
1625
|
+
body += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
|
|
1626
|
+
esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
|
|
1627
|
+
esc(c.id) + '">Cancel</button></div>';
|
|
1628
|
+
el.innerHTML = body;
|
|
1629
|
+
list.appendChild(el);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
async function credentialRequest(connector, method, action, body, generation) {
|
|
1180
1634
|
const token = await sessionToken();
|
|
1635
|
+
if (generation !== SESSION_GENERATION) {
|
|
1636
|
+
throw new Error("The operator session changed.");
|
|
1637
|
+
}
|
|
1181
1638
|
if (!token) throw new Error("Your Clerk session has expired.");
|
|
1182
1639
|
const suffix = action ? "/" + action : "";
|
|
1183
1640
|
const res = await fetch(
|
|
@@ -1203,12 +1660,13 @@ function credentialForm(connector) {
|
|
|
1203
1660
|
CSS.escape(connector) + '"]');
|
|
1204
1661
|
}
|
|
1205
1662
|
|
|
1206
|
-
$("
|
|
1663
|
+
$("credentialList").onclick = async (event) => {
|
|
1207
1664
|
const button = event.target.closest("[data-credential-action]");
|
|
1208
1665
|
if (!button) return;
|
|
1209
1666
|
const connector = button.dataset.connector;
|
|
1210
1667
|
const action = button.dataset.credentialAction;
|
|
1211
1668
|
const form = credentialForm(connector);
|
|
1669
|
+
const generation = SESSION_GENERATION;
|
|
1212
1670
|
|
|
1213
1671
|
if (action === "edit") {
|
|
1214
1672
|
form.classList.remove("hidden");
|
|
@@ -1218,6 +1676,10 @@ $("list").onclick = async (event) => {
|
|
|
1218
1676
|
if (action === "cancel") {
|
|
1219
1677
|
form.querySelectorAll("input").forEach((input) => { input.value = ""; });
|
|
1220
1678
|
form.classList.add("hidden");
|
|
1679
|
+
document.querySelector(
|
|
1680
|
+
'[data-credential-action="edit"][data-connector="' +
|
|
1681
|
+
CSS.escape(connector) + '"]'
|
|
1682
|
+
)?.focus();
|
|
1221
1683
|
return;
|
|
1222
1684
|
}
|
|
1223
1685
|
if (action === "remove" && !window.confirm(
|
|
@@ -1225,6 +1687,7 @@ $("list").onclick = async (event) => {
|
|
|
1225
1687
|
)) return;
|
|
1226
1688
|
|
|
1227
1689
|
setNotice("");
|
|
1690
|
+
$("credentialList").setAttribute("aria-busy", "true");
|
|
1228
1691
|
const buttons = [...document.querySelectorAll(
|
|
1229
1692
|
'[data-connector="' + CSS.escape(connector) + '"]'
|
|
1230
1693
|
)];
|
|
@@ -1239,42 +1702,65 @@ $("list").onclick = async (event) => {
|
|
|
1239
1702
|
if (!value) throw new Error("Complete every credential field before saving.");
|
|
1240
1703
|
values[input.dataset.credentialField] = value;
|
|
1241
1704
|
}
|
|
1242
|
-
await credentialRequest(connector, "PUT", "", { values });
|
|
1705
|
+
await credentialRequest(connector, "PUT", "", { values }, generation);
|
|
1243
1706
|
} else {
|
|
1244
1707
|
const input = form.querySelector("[data-credential-input]");
|
|
1245
1708
|
const value = input.value.trim();
|
|
1246
1709
|
if (!value) throw new Error("Paste a credential before saving.");
|
|
1247
|
-
await credentialRequest(connector, "PUT", "", { value });
|
|
1710
|
+
await credentialRequest(connector, "PUT", "", { value }, generation);
|
|
1248
1711
|
}
|
|
1712
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1249
1713
|
form.querySelectorAll("input").forEach((input) => { input.value = ""; });
|
|
1250
1714
|
setNotice("Credential saved.");
|
|
1251
1715
|
await load();
|
|
1716
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1717
|
+
$("credentialNotice").focus();
|
|
1252
1718
|
} else if (action === "remove") {
|
|
1253
|
-
await credentialRequest(connector, "DELETE");
|
|
1719
|
+
await credentialRequest(connector, "DELETE", "", null, generation);
|
|
1720
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1254
1721
|
setNotice("Credential removed.");
|
|
1255
1722
|
await load();
|
|
1723
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1724
|
+
$("credentialNotice").focus();
|
|
1256
1725
|
} else if (action === "test") {
|
|
1257
|
-
const result = await credentialRequest(
|
|
1726
|
+
const result = await credentialRequest(
|
|
1727
|
+
connector,
|
|
1728
|
+
"POST",
|
|
1729
|
+
"test",
|
|
1730
|
+
null,
|
|
1731
|
+
generation,
|
|
1732
|
+
);
|
|
1733
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1258
1734
|
setNotice(
|
|
1259
1735
|
result.message || (result.ok ? "Credential is valid." : "Credential test failed."),
|
|
1260
1736
|
!result.ok,
|
|
1261
1737
|
);
|
|
1262
1738
|
}
|
|
1263
1739
|
} catch (e) {
|
|
1740
|
+
if (generation !== SESSION_GENERATION) return;
|
|
1264
1741
|
setNotice(e.message || "Credential action failed.", true);
|
|
1265
1742
|
} finally {
|
|
1266
|
-
|
|
1743
|
+
if (generation === SESSION_GENERATION) {
|
|
1744
|
+
$("credentialList").setAttribute("aria-busy", "false");
|
|
1745
|
+
buttons.forEach((item) => { item.disabled = false; });
|
|
1746
|
+
}
|
|
1267
1747
|
}
|
|
1268
1748
|
};
|
|
1269
1749
|
|
|
1270
|
-
$("save").onclick = () => {
|
|
1750
|
+
$("save").onclick = async () => {
|
|
1271
1751
|
const v = $("token").value.trim();
|
|
1272
1752
|
if (!v) return;
|
|
1753
|
+
clearIdentityState();
|
|
1273
1754
|
localStorage.setItem(KEY, v);
|
|
1274
1755
|
$("token").value = "";
|
|
1275
|
-
load();
|
|
1756
|
+
await load();
|
|
1757
|
+
if (DATA) $(CURRENT_PAGE + "Heading").focus();
|
|
1758
|
+
};
|
|
1759
|
+
$("change").onclick = () => {
|
|
1760
|
+
localStorage.removeItem(KEY);
|
|
1761
|
+
showGate("");
|
|
1762
|
+
$("token").focus();
|
|
1276
1763
|
};
|
|
1277
|
-
$("change").onclick = () => { localStorage.removeItem(KEY); showGate(""); };
|
|
1278
1764
|
$("copyMcpUrl").onclick = async () => {
|
|
1279
1765
|
const button = $("copyMcpUrl");
|
|
1280
1766
|
try {
|
|
@@ -1289,14 +1775,35 @@ $("signin").onclick = () => Clerk.redirectToSignIn({
|
|
|
1289
1775
|
signInFallbackRedirectUrl: window.location.href,
|
|
1290
1776
|
signUpFallbackRedirectUrl: window.location.href,
|
|
1291
1777
|
});
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
$("
|
|
1778
|
+
function signOut() {
|
|
1779
|
+
clearIdentityState();
|
|
1780
|
+
return Clerk.signOut({ redirectUrl: window.location.href });
|
|
1781
|
+
}
|
|
1782
|
+
$("gateSignout").onclick = signOut;
|
|
1783
|
+
$("signout").onclick = signOut;
|
|
1784
|
+
$("filter").oninput = () => { if (DATA) renderConnections(); };
|
|
1297
1785
|
$("refreshActivity").onclick = () => loadActivity(true);
|
|
1298
1786
|
$("moreActivity").onclick = () => loadActivity(false);
|
|
1299
1787
|
$("activitySearch").oninput = () => renderActivity();
|
|
1788
|
+
document.addEventListener("click", (event) => {
|
|
1789
|
+
const link = event.target.closest("a[data-operator-page]");
|
|
1790
|
+
if (
|
|
1791
|
+
!link ||
|
|
1792
|
+
event.defaultPrevented ||
|
|
1793
|
+
event.button !== 0 ||
|
|
1794
|
+
event.metaKey ||
|
|
1795
|
+
event.ctrlKey ||
|
|
1796
|
+
event.shiftKey ||
|
|
1797
|
+
event.altKey
|
|
1798
|
+
) return;
|
|
1799
|
+
const target = new URL(link.href, window.location.href);
|
|
1800
|
+
if (target.origin !== window.location.origin) return;
|
|
1801
|
+
event.preventDefault();
|
|
1802
|
+
navigateTo(link.dataset.operatorPage, target.pathname + target.search + target.hash);
|
|
1803
|
+
});
|
|
1804
|
+
window.addEventListener("popstate", () => {
|
|
1805
|
+
activatePage(pageForPath(window.location.pathname), { focus: true });
|
|
1806
|
+
});
|
|
1300
1807
|
|
|
1301
1808
|
async function init() {
|
|
1302
1809
|
if (AUTH.kind === "clerk") {
|
|
@@ -1320,6 +1827,7 @@ async function init() {
|
|
|
1320
1827
|
$("tokenGate").classList.remove("hidden");
|
|
1321
1828
|
$("change").classList.remove("hidden");
|
|
1322
1829
|
}
|
|
1830
|
+
activatePage(pageForPath(window.location.pathname));
|
|
1323
1831
|
await load();
|
|
1324
1832
|
}
|
|
1325
1833
|
|