@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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.
Files changed (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -21,6 +21,7 @@
21
21
  import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
22
22
  import { renderCsrfHiddenInput } from "./csrf.ts";
23
23
  import { escapeHtml } from "./oauth-ui.ts";
24
+ import type { VaultVerb } from "./users.ts";
24
25
 
25
26
  // --- shared chrome (mirrors account-change-password-ui.ts) ----------------
26
27
 
@@ -109,8 +110,56 @@ export interface RenderAccountHomeOpts {
109
110
  * to keep a cross-origin form from logging the user out.
110
111
  */
111
112
  csrfToken: string;
113
+ /**
114
+ * Whether the signed-in user has TOTP 2FA enrolled (hub#473). Drives the
115
+ * Account card's 2FA status line + enroll/manage link. The link points at
116
+ * `/account/2fa` either way — "Set up" when off, "Manage" when on.
117
+ */
118
+ twoFactorEnabled: boolean;
119
+ /**
120
+ * Per-vault mintable verbs for the "mint an access token" affordance on
121
+ * each vault tile (friend headless-client path). Maps `vaultName` → the
122
+ * verbs the user's assignment role permits (today always
123
+ * `["read", "write"]` since every `user_vaults.role` is `'write'`). A
124
+ * vault absent from this map (or mapped to an empty list) renders no mint
125
+ * affordance — the UI never offers a verb the server would reject. The
126
+ * `/account/` GET handler builds this from `vaultVerbsForUserVault` for
127
+ * each assigned vault. Omitted (or empty) for the admin / no-vault
128
+ * branches, where no token-mint tile is shown.
129
+ */
130
+ mintableVerbs?: Record<string, VaultVerb[]>;
131
+ /**
132
+ * Set after a successful `POST /account/vault-token/<name>` to show the
133
+ * freshly-minted token ONCE (the only time it's ever shown — the hub keeps
134
+ * no plaintext copy). Drives the show-once banner at the top of the page.
135
+ * Absent on the normal GET render.
136
+ */
137
+ mintedToken?: MintedTokenView;
138
+ /**
139
+ * Set after a `POST /account/vault-token/<name>` that failed authorization
140
+ * or validation, to surface an inline error banner on the re-rendered page
141
+ * (e.g. unassigned vault, capped verb, rate-limited). Absent on success and
142
+ * on the normal GET render.
143
+ */
144
+ mintError?: string;
112
145
  }
113
146
 
147
+ /**
148
+ * The one-time view of a freshly-minted friend vault token. The hub stores
149
+ * only a hash-keyed registry row (no plaintext), so this is the single moment
150
+ * the token string is shown — the UI copy makes that explicit.
151
+ */
152
+ export interface MintedTokenView {
153
+ vaultName: string;
154
+ verb: VaultVerb;
155
+ token: string;
156
+ /** Whole-day TTL for the "expires in N days" copy. */
157
+ expiresInDays: number;
158
+ }
159
+
160
+ /** Friend-mint token default lifetime: 90 days (matches the CLI/api-mint default). */
161
+ export const ACCOUNT_VAULT_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
162
+
114
163
  export function renderAccountHome(opts: RenderAccountHomeOpts): string {
115
164
  const { username, assignedVaults, hubOrigin, isFirstAdmin, csrfToken } = opts;
116
165
  const safeUsername = escapeHtml(username);
@@ -118,15 +167,25 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
118
167
  // but defensively re-trim so a stray slash here doesn't break the CTA href.
119
168
  const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
120
169
 
170
+ const mintedBanner = opts.mintedToken ? renderMintedTokenBanner(opts.mintedToken) : "";
171
+ const mintErrorBanner = opts.mintError
172
+ ? `<div class="mint-error-banner" data-testid="mint-error-banner" role="alert">${escapeHtml(
173
+ opts.mintError,
174
+ )}</div>`
175
+ : "";
176
+
121
177
  const vaultCard = renderVaultCard({
122
178
  assignedVaults,
123
179
  trimmedOrigin,
124
180
  isFirstAdmin,
181
+ csrfToken,
182
+ mintableVerbs: opts.mintableVerbs ?? {},
125
183
  });
126
184
 
127
185
  const accountCard = renderAccountCard({
128
186
  username: safeUsername,
129
187
  csrfToken,
188
+ twoFactorEnabled: opts.twoFactorEnabled,
130
189
  });
131
190
 
132
191
  const body = `
@@ -136,9 +195,11 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
136
195
  <h1>Welcome, ${safeUsername}</h1>
137
196
  <p class="subtitle">Your Parachute account home.</p>
138
197
  </div>
198
+ ${mintedBanner}
199
+ ${mintErrorBanner}
139
200
  ${vaultCard}
140
201
  ${accountCard}
141
- </div>`;
202
+ </div>${COPY_SCRIPT}`;
142
203
  return baseDocument(`${username} — Parachute`, body);
143
204
  }
144
205
 
@@ -146,49 +207,126 @@ interface VaultCardOpts {
146
207
  assignedVaults: string[];
147
208
  trimmedOrigin: string;
148
209
  isFirstAdmin: boolean;
210
+ csrfToken: string;
211
+ mintableVerbs: Record<string, VaultVerb[]>;
212
+ }
213
+
214
+ /**
215
+ * Build `<hub-origin>/vault/<name>/mcp` — the MCP endpoint a client connects
216
+ * to. Mirrors the SPA's `mcpEndpointFor` (McpConnectCard.tsx) and the
217
+ * wizard's `renderMcpTile`. The friend's `/account/` tile is server-rendered
218
+ * (no SPA), so we compute it here from the hub origin + vault name rather
219
+ * than the vault's well-known `url`.
220
+ *
221
+ * Exported for direct unit testing.
222
+ */
223
+ export function accountMcpEndpoint(trimmedOrigin: string, vaultName: string): string {
224
+ return `${trimmedOrigin}/vault/${vaultName}/mcp`;
225
+ }
226
+
227
+ /**
228
+ * The OAuth-path `claude mcp add` command — no token, triggers browser OAuth
229
+ * on first use. Same `parachute-<name>` server name as the SPA card and the
230
+ * wizard tile, so a friend and the operator end up with identically-named
231
+ * MCP servers. Exported for direct unit testing.
232
+ */
233
+ export function accountClaudeMcpAddCommand(trimmedOrigin: string, vaultName: string): string {
234
+ return `claude mcp add --transport http parachute-${vaultName} ${accountMcpEndpoint(
235
+ trimmedOrigin,
236
+ vaultName,
237
+ )}`;
149
238
  }
150
239
 
151
240
  function renderVaultCard(opts: VaultCardOpts): string {
152
- const { assignedVaults, trimmedOrigin, isFirstAdmin } = opts;
241
+ const { assignedVaults, trimmedOrigin, isFirstAdmin, csrfToken, mintableVerbs } = opts;
153
242
 
154
243
  if (assignedVaults.length > 0) {
155
- // One vault tile per assignment (multi-user Phase 2 PR 2). Each
156
- // tile carries its own Notes "Open" CTA and the hub-origin code
157
- // block. The disclosure ("Use a custom client") lives at the
158
- // section level, not per-tile, because the hub origin is the same
159
- // regardless of which vault the user picks.
160
- const hubOriginDisplay = escapeHtml(trimmedOrigin);
244
+ // One vault tile per assignment (multi-user Phase 2 PR 2). Each tile
245
+ // leads with a friendly "connect your AI assistant to this vault" block
246
+ // that covers BOTH connect paths a non-technical friend is likely to
247
+ // use Claude Code (the `claude mcp add` CLI command) and Claude.ai on
248
+ // the web (Settings Connectors Add custom connector, pointed at the
249
+ // endpoint). Both are the OAuth path — no token to paste, the first
250
+ // connection opens a browser to sign in + approve. The Notes "Open" CTA
251
+ // sits alongside as the browser-UI option. Phrasing mirrors
252
+ // parachute.computer/install.njk's #connect-mcp-clients section so the
253
+ // operator docs and the friend's account page stay consistent.
254
+ //
255
+ // This closes the multi-user gap where the friend tile read as MCP
256
+ // jargon ("Connect an MCP client") rather than "here's how to connect
257
+ // this to your AI" — and where the web (Claude.ai) path was entirely
258
+ // missing, only the Claude Code CLI command was offered.
161
259
  const heading = assignedVaults.length === 1 ? "<h2>Your vault</h2>" : "<h2>Your vaults</h2>";
162
260
  const tiles = assignedVaults
163
261
  .map((vaultName) => {
164
262
  const safeVault = escapeHtml(vaultName);
165
263
  const vaultUrlForAdd = encodeURIComponent(`${trimmedOrigin}/vault/${vaultName}`);
264
+ const endpoint = accountMcpEndpoint(trimmedOrigin, vaultName);
265
+ const addCmd = accountClaudeMcpAddCommand(trimmedOrigin, vaultName);
266
+ const safeEndpoint = escapeHtml(endpoint);
267
+ const safeAddCmd = escapeHtml(addCmd);
268
+ const tokenMintBlock = renderTokenMintBlock(
269
+ vaultName,
270
+ safeVault,
271
+ mintableVerbs[vaultName] ?? [],
272
+ csrfToken,
273
+ );
166
274
  return `
167
275
  <div class="vault-tile" data-testid="vault-tile" data-vault-name="${safeVault}">
168
276
  <p class="vault-name"><strong>${safeVault}</strong></p>
169
- <p>
277
+ <div class="mcp-connect" data-testid="mcp-connect">
278
+ <p class="mcp-connect-label" data-testid="connect-ai-heading">Connect your AI
279
+ assistant to this vault</p>
280
+ <p class="mcp-connect-intro">Two common ways. Both sign you in to this hub over
281
+ HTTPS and ask you to approve access the first time — no token to copy.</p>
282
+
283
+ <div class="mcp-method" data-testid="connect-method-claude-code">
284
+ <p class="mcp-method-title">Claude Code (terminal)</p>
285
+ <p class="mcp-method-sub">Run this in your terminal:</p>
286
+ <div class="copy-row">
287
+ <code data-testid="mcp-add-command">${safeAddCmd}</code>
288
+ <button type="button" class="btn btn-copy" data-copy="${safeAddCmd}"
289
+ data-testid="copy-mcp-add-command">Copy</button>
290
+ </div>
291
+ </div>
292
+
293
+ <div class="mcp-method" data-testid="connect-method-claude-ai">
294
+ <p class="mcp-method-title">Claude.ai (web)</p>
295
+ <p class="mcp-method-sub">In Claude.ai, open <strong>Settings → Connectors</strong>,
296
+ choose <strong>Add custom connector</strong>, and paste this endpoint:</p>
297
+ <div class="copy-row">
298
+ <code data-testid="mcp-endpoint">${safeEndpoint}</code>
299
+ <button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
300
+ data-testid="copy-mcp-endpoint">Copy</button>
301
+ </div>
302
+ <p class="mcp-method-note">Claude.ai then redirects you here to sign in and
303
+ approve. (Your hub must be reachable from the web for this.)</p>
304
+ </div>
305
+
306
+ <p class="mcp-connect-hint" data-testid="connect-any-client-hint">Any other MCP
307
+ client (Codex, Goose, Cursor, your own agent): point it at the same endpoint
308
+ above over HTTP.</p>
309
+ </div>
310
+ <p class="vault-notes-cta">
170
311
  <a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
171
312
  target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
313
+ <span class="vault-notes-cta-sub">Prefer a browser UI? Open Notes to browse +
314
+ capture in this vault.</span>
172
315
  </p>
316
+ ${tokenMintBlock}
173
317
  </div>`;
174
318
  })
175
319
  .join("");
176
320
  return `
177
321
  <section class="section" data-testid="vault-card">
178
322
  ${heading}
179
- <p>Open Notes the canonical browser UI for your vault${
323
+ <p>Connect Claude (or any AI assistant) to your vault${
180
324
  assignedVaults.length === 1 ? "" : "s"
181
- }. It connects to your hub
182
- over HTTPS and remembers your URL after the first OAuth.</p>
325
+ } pick Claude Code or
326
+ Claude.ai below or open Notes for a browser UI. The first connection signs you in
327
+ to your hub over HTTPS and asks you to approve access.</p>
183
328
  <div class="vault-tiles">${tiles}
184
329
  </div>
185
- <details class="custom-client">
186
- <summary>Use a custom client</summary>
187
- <p>To connect a custom Surface or MCP client running on your own machine,
188
- point it at the hub origin below and run through OAuth — the hub will
189
- ask you to consent.</p>
190
- <p class="hub-origin-line">Hub origin: <code>${hubOriginDisplay}</code></p>
191
- </details>
192
330
  </section>`;
193
331
  }
194
332
  if (isFirstAdmin) {
@@ -207,28 +345,140 @@ function renderVaultCard(opts: VaultCardOpts): string {
207
345
  return `
208
346
  <section class="section" data-testid="no-vault-card">
209
347
  <h2>Your vault</h2>
210
- <p>Your account isn't assigned to a vault yet. Ask the hub operator
211
- to assign one.</p>
348
+ <p>You don't have a vault yet, so there's nothing to connect to. A vault
349
+ is your personal knowledge store on this hub — once the operator
350
+ assigns you one, this page will show you how to connect Claude (or
351
+ any AI assistant) to it.</p>
352
+ <p><strong>Ask the hub operator to assign you a vault.</strong></p>
212
353
  </section>`;
213
354
  }
214
355
 
356
+ /**
357
+ * The "mint an access token (for scripts / headless clients)" affordance on a
358
+ * vault tile. Sits BELOW the OAuth connect block + Notes CTA — the no-token
359
+ * OAuth path stays the recommended default; this is the secondary, opt-in
360
+ * path for clients that can't do an interactive browser sign-in (cron jobs,
361
+ * headless agents, a `curl` script).
362
+ *
363
+ * Renders one radio per verb the user's assignment role permits (`verbs` —
364
+ * today always `["read", "write"]`). The UI NEVER offers a verb the server
365
+ * would reject: a read-only assignment shows only "Read". An empty `verbs`
366
+ * list (unknown / unmappable role) renders nothing — fail-closed, matching
367
+ * the server's `vaultVerbsForUserVault` returning `[]`.
368
+ *
369
+ * The form POSTs `application/x-www-form-urlencoded` to
370
+ * `/account/vault-token/<name>` with the CSRF hidden field + a `verb` radio —
371
+ * same no-JS-required posture as the change-password and sign-out forms. The
372
+ * `<details>` keeps it collapsed by default so the tile leads with the
373
+ * recommended OAuth path.
374
+ */
375
+ function renderTokenMintBlock(
376
+ vaultName: string,
377
+ safeVault: string,
378
+ verbs: VaultVerb[],
379
+ csrfToken: string,
380
+ ): string {
381
+ if (verbs.length === 0) return "";
382
+ // Path segment is URL-encoded; the action attribute is HTML-escaped on top.
383
+ const action = escapeHtml(`/account/vault-token/${encodeURIComponent(vaultName)}`);
384
+ const radios = verbs
385
+ .map((verb, i) => {
386
+ const checked = i === 0 ? " checked" : "";
387
+ const label = verb === "read" ? "Read-only" : "Read + write";
388
+ return `
389
+ <label class="mint-verb-option">
390
+ <input type="radio" name="verb" value="${verb}"${checked}
391
+ data-testid="mint-verb-${verb}" />
392
+ <span><strong>${verb}</strong> — ${label} access to <code>${safeVault}</code></span>
393
+ </label>`;
394
+ })
395
+ .join("");
396
+ return `
397
+ <details class="token-mint" data-testid="token-mint">
398
+ <summary data-testid="token-mint-summary">Mint an access token
399
+ <span class="token-mint-sub">for scripts / headless clients</span></summary>
400
+ <div class="token-mint-body">
401
+ <p class="token-mint-intro">Most clients should use the no-token
402
+ connect options above — they sign you in over HTTPS and never
403
+ ask you to paste a secret. Mint a token only for a script or
404
+ headless client that can't open a browser to sign in. It's a
405
+ bearer for <code>vault:${safeVault}:&lt;verb&gt;</code>, scoped to
406
+ <strong>this vault only</strong>, and you'll see it once.</p>
407
+ <form method="POST" action="${action}" class="mint-form"
408
+ data-testid="mint-form">
409
+ ${renderCsrfHiddenInput(csrfToken)}
410
+ <fieldset class="mint-verbs">
411
+ <legend>Access level</legend>${radios}
412
+ </fieldset>
413
+ <button type="submit" class="btn btn-secondary"
414
+ data-testid="mint-token-button">Mint token</button>
415
+ </form>
416
+ </div>
417
+ </details>`;
418
+ }
419
+
420
+ /**
421
+ * The show-once banner for a freshly-minted friend vault token. Rendered at
422
+ * the top of `/account/` after a successful `POST /account/vault-token/<name>`.
423
+ * The token string appears here and NOWHERE else — the hub stores only a
424
+ * hash-keyed registry row. The copy is explicit about that ("save it now —
425
+ * it won't be shown again"). No revoke link: there's no friend-facing revoke
426
+ * surface today, so we don't claim one.
427
+ */
428
+ function renderMintedTokenBanner(view: MintedTokenView): string {
429
+ const safeVault = escapeHtml(view.vaultName);
430
+ const scope = `vault:${view.vaultName}:${view.verb}`;
431
+ const safeScope = escapeHtml(scope);
432
+ // The token value goes in a data attribute for the copy button + as text.
433
+ // It's a hub-signed JWT (no HTML-significant chars in base64url + dots), but
434
+ // escape defensively all the same.
435
+ const safeToken = escapeHtml(view.token);
436
+ return `
437
+ <div class="minted-banner" data-testid="minted-token-banner" role="status">
438
+ <p class="minted-title">Your access token for <code>${safeVault}</code></p>
439
+ <p class="minted-warn"><strong>Save it now — it won't be shown again.</strong>
440
+ This is a bearer credential for <code>${safeScope}</code>. It expires in
441
+ ${view.expiresInDays} days. Treat it like a password; anyone who has it
442
+ can act on this vault at that access level.</p>
443
+ <div class="copy-row">
444
+ <code data-testid="minted-token-value">${safeToken}</code>
445
+ <button type="button" class="btn btn-copy" data-copy="${safeToken}"
446
+ data-testid="copy-minted-token">Copy</button>
447
+ </div>
448
+ <p class="minted-hint">Use it as <code>Authorization: Bearer &lt;token&gt;</code>
449
+ when calling this vault's MCP endpoint. To revoke it, ask the hub operator.</p>
450
+ </div>`;
451
+ }
452
+
215
453
  interface AccountCardOpts {
216
454
  username: string;
217
455
  csrfToken: string;
456
+ twoFactorEnabled: boolean;
218
457
  }
219
458
 
220
459
  function renderAccountCard(opts: AccountCardOpts): string {
221
- const { username, csrfToken } = opts;
460
+ const { username, csrfToken, twoFactorEnabled } = opts;
461
+ const twoFactorStatus = twoFactorEnabled
462
+ ? `<dd><span class="badge badge-on" data-testid="2fa-status">On</span></dd>`
463
+ : `<dd><span class="badge badge-off" data-testid="2fa-status">Off</span></dd>`;
464
+ const twoFactorLink = twoFactorEnabled
465
+ ? `<a class="account-action" href="/account/2fa" data-testid="manage-2fa-link">Manage two-factor →</a>`
466
+ : `<a class="account-action" href="/account/2fa" data-testid="setup-2fa-link">Set up two-factor →</a>`;
222
467
  return `
223
468
  <section class="section" data-testid="account-card">
224
469
  <h2>Account</h2>
225
470
  <dl class="kv">
226
471
  <dt>Username</dt>
227
472
  <dd><code>${username}</code></dd>
473
+ <dt>Two-factor authentication</dt>
474
+ ${twoFactorStatus}
228
475
  </dl>
229
476
  <p>
230
477
  <a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
231
478
  </p>
479
+ <p>
480
+ ${twoFactorLink}
481
+ </p>
232
482
  <form method="POST" action="/logout" class="signout-form" data-testid="signout-form">
233
483
  ${renderCsrfHiddenInput(csrfToken)}
234
484
  <button type="submit" class="btn btn-secondary">Sign out</button>
@@ -236,12 +486,41 @@ function renderAccountCard(opts: AccountCardOpts): string {
236
486
  </section>`;
237
487
  }
238
488
 
489
+ // --- copy-button script ---------------------------------------------------
490
+ //
491
+ // Tiny inline progressive-enhancement script for the page's copy buttons
492
+ // (the per-tile MCP command/endpoint buttons AND the show-once minted-token
493
+ // banner's Copy button). Delegated click handler reads the value from the
494
+ // button's `data-copy` attribute and writes it to the clipboard, flashing
495
+ // "Copied ✓" for 2s. No-ops gracefully when the Clipboard API is unavailable
496
+ // (insecure context, older browser) — the value stays selectable in the
497
+ // codebox. Mirrors the SPA `CopyButton` posture (McpConnectCard.tsx) for a
498
+ // surface that has no React. Emitted once at the page body level so it covers
499
+ // both the vault tiles and the minted-token banner (which lives above the
500
+ // vault card).
501
+ const COPY_SCRIPT = `
502
+ <script>
503
+ (function () {
504
+ document.addEventListener('click', function (e) {
505
+ var btn = e.target && e.target.closest ? e.target.closest('[data-copy]') : null;
506
+ if (!btn) return;
507
+ var value = btn.getAttribute('data-copy') || '';
508
+ if (typeof navigator === 'undefined' || !navigator.clipboard) return;
509
+ navigator.clipboard.writeText(value).then(function () {
510
+ var original = btn.textContent;
511
+ btn.textContent = 'Copied \\u2713';
512
+ setTimeout(function () { btn.textContent = original; }, 2000);
513
+ }).catch(function () { /* insecure context — leave selectable */ });
514
+ });
515
+ })();
516
+ </script>`;
517
+
239
518
  // --- styles ---------------------------------------------------------------
240
519
  //
241
520
  // Same brand palette + font stack as account-change-password-ui.ts so the
242
521
  // `/account/*` family is visually cohesive. Extra rules (.section, .kv,
243
- // .vault-name, .custom-client, .hub-origin-line) describe the new card +
244
- // disclosure shapes this page introduces.
522
+ // .vault-name, .mcp-connect, .copy-row) describe the new card + MCP
523
+ // connect-block shapes this page introduces.
245
524
 
246
525
  const STYLES = `
247
526
  *, *::before, *::after { box-sizing: border-box; }
@@ -338,22 +617,173 @@ const STYLES = `
338
617
  .vault-tile p { margin: 0.2rem 0; }
339
618
  .vault-tile p:last-child { margin-top: 0.5rem; }
340
619
 
341
- .custom-client { margin-top: 0.8rem; }
342
- .custom-client summary {
343
- cursor: pointer;
620
+ .mcp-connect {
621
+ margin-bottom: 0.75rem;
622
+ }
623
+ .mcp-connect-label {
624
+ font-family: ${FONT_SERIF};
625
+ font-size: 1.05rem;
626
+ font-weight: 400;
627
+ color: ${PALETTE.fg};
628
+ margin: 0 0 0.3rem;
629
+ }
630
+ .mcp-connect-intro {
344
631
  font-size: 0.85rem;
345
632
  color: ${PALETTE.fgMuted};
346
- font-family: ${FONT_MONO};
347
- padding: 0.25rem 0;
348
- user-select: none;
633
+ margin: 0 0 0.75rem;
349
634
  }
350
- .custom-client[open] summary { color: ${PALETTE.fg}; }
351
- .custom-client p {
635
+ .mcp-method {
636
+ margin: 0.75rem 0;
637
+ padding-top: 0.6rem;
638
+ border-top: 1px solid ${PALETTE.borderLight};
639
+ }
640
+ .mcp-method-title {
352
641
  font-size: 0.9rem;
642
+ font-weight: 600;
643
+ color: ${PALETTE.fg};
644
+ margin: 0 0 0.15rem;
645
+ }
646
+ .mcp-method-sub {
647
+ font-size: 0.82rem;
353
648
  color: ${PALETTE.fgMuted};
354
- margin: 0.4rem 0;
649
+ margin: 0 0 0.4rem;
650
+ }
651
+ .mcp-method-note {
652
+ font-size: 0.78rem;
653
+ color: ${PALETTE.fgMuted};
654
+ margin: 0.35rem 0 0;
355
655
  }
356
- .hub-origin-line { font-family: ${FONT_MONO}; }
656
+ .mcp-field { margin: 0.5rem 0; }
657
+ .mcp-field-label {
658
+ display: block;
659
+ font-size: 0.7rem;
660
+ text-transform: uppercase;
661
+ letter-spacing: 0.06em;
662
+ color: ${PALETTE.fgMuted};
663
+ font-family: ${FONT_MONO};
664
+ margin-bottom: 0.2rem;
665
+ }
666
+ .vault-notes-cta {
667
+ margin: 0.9rem 0 0;
668
+ padding-top: 0.6rem;
669
+ border-top: 1px solid ${PALETTE.borderLight};
670
+ display: flex;
671
+ align-items: center;
672
+ flex-wrap: wrap;
673
+ gap: 0.5rem 0.75rem;
674
+ }
675
+ .vault-notes-cta-sub {
676
+ font-size: 0.82rem;
677
+ color: ${PALETTE.fgMuted};
678
+ flex: 1 1 12rem;
679
+ }
680
+ .copy-row {
681
+ display: flex;
682
+ align-items: center;
683
+ gap: 0.5rem;
684
+ background: ${PALETTE.cardBg};
685
+ border: 1px solid ${PALETTE.borderLight};
686
+ border-radius: 6px;
687
+ padding: 0.4rem 0.5rem;
688
+ }
689
+ .copy-row code {
690
+ flex: 1 1 auto;
691
+ overflow-x: auto;
692
+ white-space: nowrap;
693
+ background: transparent;
694
+ padding: 0;
695
+ font-size: 0.82rem;
696
+ }
697
+ .btn-copy {
698
+ flex: 0 0 auto;
699
+ font-size: 0.8rem;
700
+ padding: 0.3rem 0.7rem;
701
+ background: transparent;
702
+ color: ${PALETTE.fg};
703
+ border-color: ${PALETTE.border};
704
+ }
705
+ .btn-copy:hover { background: ${PALETTE.bgSoft}; border-color: ${PALETTE.accent}; }
706
+ .mcp-connect-hint {
707
+ font-size: 0.82rem;
708
+ color: ${PALETTE.fgMuted};
709
+ margin: 0.4rem 0 0;
710
+ }
711
+
712
+ .token-mint {
713
+ margin: 0.9rem 0 0;
714
+ padding-top: 0.6rem;
715
+ border-top: 1px solid ${PALETTE.borderLight};
716
+ }
717
+ .token-mint > summary {
718
+ cursor: pointer;
719
+ font-size: 0.88rem;
720
+ font-weight: 600;
721
+ color: ${PALETTE.fg};
722
+ list-style: revert;
723
+ }
724
+ .token-mint-sub {
725
+ font-weight: 400;
726
+ font-size: 0.8rem;
727
+ color: ${PALETTE.fgMuted};
728
+ }
729
+ .token-mint-body { margin-top: 0.6rem; }
730
+ .token-mint-intro {
731
+ font-size: 0.8rem;
732
+ color: ${PALETTE.fgMuted};
733
+ margin: 0 0 0.6rem;
734
+ }
735
+ .mint-verbs {
736
+ border: 1px solid ${PALETTE.borderLight};
737
+ border-radius: 6px;
738
+ padding: 0.5rem 0.7rem;
739
+ margin: 0 0 0.6rem;
740
+ }
741
+ .mint-verbs legend {
742
+ font-size: 0.7rem;
743
+ text-transform: uppercase;
744
+ letter-spacing: 0.06em;
745
+ color: ${PALETTE.fgMuted};
746
+ font-family: ${FONT_MONO};
747
+ padding: 0 0.3rem;
748
+ }
749
+ .mint-verb-option {
750
+ display: flex;
751
+ align-items: baseline;
752
+ gap: 0.5rem;
753
+ font-size: 0.85rem;
754
+ margin: 0.3rem 0;
755
+ }
756
+ .mint-verb-option input { margin: 0; }
757
+ .mint-form .btn { margin-top: 0.2rem; }
758
+
759
+ .minted-banner {
760
+ border: 1px solid ${PALETTE.accent};
761
+ background: ${PALETTE.accentSoft};
762
+ border-radius: 8px;
763
+ padding: 0.9rem 1rem;
764
+ margin: 1.25rem 0 0;
765
+ }
766
+ .minted-title {
767
+ font-family: ${FONT_SERIF};
768
+ font-size: 1.05rem;
769
+ margin: 0 0 0.3rem;
770
+ color: ${PALETTE.fg};
771
+ }
772
+ .minted-warn { font-size: 0.85rem; margin: 0 0 0.6rem; color: ${PALETTE.fg}; }
773
+ .minted-banner .copy-row { margin: 0.4rem 0; }
774
+ .minted-banner .copy-row code { font-size: 0.72rem; }
775
+ .minted-hint { font-size: 0.8rem; color: ${PALETTE.fgMuted}; margin: 0.4rem 0 0; }
776
+
777
+ .mint-error-banner {
778
+ border: 1px solid ${PALETTE.danger};
779
+ background: ${PALETTE.dangerSoft};
780
+ color: ${PALETTE.danger};
781
+ border-radius: 8px;
782
+ padding: 0.7rem 1rem;
783
+ margin: 1.25rem 0 0;
784
+ font-size: 0.88rem;
785
+ }
786
+
357
787
  code {
358
788
  font-family: ${FONT_MONO};
359
789
  background: ${PALETTE.bgSoft};
@@ -373,6 +803,17 @@ const STYLES = `
373
803
  }
374
804
  .kv dd { margin: 0.15rem 0 0.4rem; }
375
805
 
806
+ .badge {
807
+ display: inline-block;
808
+ font-size: 0.78rem;
809
+ font-weight: 500;
810
+ padding: 0.1rem 0.5rem;
811
+ border-radius: 999px;
812
+ border: 1px solid transparent;
813
+ }
814
+ .badge-on { background: ${PALETTE.successSoft}; color: ${PALETTE.success}; border-color: ${PALETTE.success}; }
815
+ .badge-off { background: ${PALETTE.bgSoft}; color: ${PALETTE.fgMuted}; border-color: ${PALETTE.border}; }
816
+
376
817
  .btn {
377
818
  font: inherit;
378
819
  font-weight: 500;
@@ -423,12 +864,21 @@ const STYLES = `
423
864
  body { background: #1a1815; color: #e8e4dc; }
424
865
  .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
425
866
  h1, h2 { color: #f0ece4; }
426
- .subtitle, .kv dt, .custom-client summary { color: #a8a29a; }
427
- .vault-name strong { color: #f0ece4; }
867
+ .subtitle, .kv dt, .mcp-field-label, .mcp-connect-hint,
868
+ .mcp-connect-intro, .mcp-method-sub, .mcp-method-note,
869
+ .vault-notes-cta-sub { color: #a8a29a; }
870
+ .vault-name strong, .mcp-connect-label, .mcp-method-title { color: #f0ece4; }
428
871
  code { background: #1f1c18; color: #e8e4dc; }
872
+ .copy-row code { background: transparent; }
429
873
  .section { border-top-color: #3a362f; }
874
+ .mcp-method, .vault-notes-cta, .token-mint { border-top-color: #3a362f; }
430
875
  .brand-tag { border-color: #3a362f; color: #a8a29a; }
431
- .btn-secondary { color: #e8e4dc; border-color: #3a362f; }
432
- .btn-secondary:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
876
+ .copy-row { background: #1f1c18; border-color: #3a362f; }
877
+ .btn-secondary, .btn-copy { color: #e8e4dc; border-color: #3a362f; }
878
+ .btn-secondary:hover, .btn-copy:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
879
+ .token-mint > summary { color: #f0ece4; }
880
+ .token-mint-sub, .token-mint-intro, .mint-verbs legend, .minted-hint { color: #a8a29a; }
881
+ .mint-verbs { border-color: #3a362f; }
882
+ .minted-title, .minted-warn { color: #f0ece4; }
433
883
  }
434
884
  `;