@gamaze/hicortex 0.20.3 → 0.20.4

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.
@@ -191,6 +191,68 @@
191
191
  display: inline-block; padding: 1px 8px; border-radius: 10px;
192
192
  background: rgba(128,128,128,0.18); font-size: 11px; color: var(--text-dim);
193
193
  }
194
+
195
+ /* ---- account menu (#365) ---- */
196
+ /* Dropdown under the nav account element: token reveal/copy, connect
197
+ snippets, plan & billing, sign-out. The wrapper (#nav-account) becomes
198
+ the positioning context; the panel is absolute + right-aligned under it,
199
+ above page content (the panel gets z-60). */
200
+ .hc-nav-account { position: relative; }
201
+ .acct-trigger {
202
+ display: inline-flex; align-items: center; gap: 6px;
203
+ background: transparent; color: var(--text); border: none; border-radius: 6px;
204
+ padding: 4px 8px; font: inherit; cursor: pointer;
205
+ }
206
+ .acct-trigger:hover { background: rgba(128,128,128,0.15); }
207
+ .acct-caret { color: var(--text-dim); font-size: 10px; }
208
+ .acct-menu {
209
+ display: none; position: absolute; top: calc(100% + 8px); right: 0;
210
+ min-width: 320px; max-width: min(420px, calc(100vw - 40px));
211
+ background: var(--panel); border: 1px solid var(--panel-border); border-radius: 8px;
212
+ padding: 12px; z-index: 60;
213
+ box-shadow: 0 8px 24px rgba(0,0,0,0.35);
214
+ color: var(--text); font-size: 13px; text-align: left;
215
+ }
216
+ .acct-menu.open { display: block; }
217
+ .acct-label {
218
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
219
+ color: var(--text-dim); margin: 8px 0 4px;
220
+ }
221
+ .acct-menu .acct-label:first-child { margin-top: 0; }
222
+ .acct-token-row { display: flex; align-items: center; gap: 6px; }
223
+ .acct-token {
224
+ flex: 1; min-width: 0;
225
+ font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
226
+ background: var(--bg); border: 1px solid var(--panel-border); border-radius: 4px;
227
+ padding: 4px 8px; color: var(--text);
228
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
229
+ }
230
+ .acct-btn {
231
+ background: var(--panel); color: var(--text);
232
+ border: 1px solid var(--panel-border); border-radius: 4px;
233
+ padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer;
234
+ }
235
+ .acct-btn:hover { border-color: var(--accent); color: var(--accent); }
236
+ .acct-btn:disabled { opacity: 0.5; cursor: default; }
237
+ .acct-menu details { border-top: 1px solid var(--panel-border); margin-top: 10px; padding-top: 8px; }
238
+ .acct-menu summary { cursor: pointer; color: var(--text-dim); font-size: 13px; }
239
+ .acct-menu summary:hover { color: var(--accent); }
240
+ .acct-snippet { margin-bottom: 8px; }
241
+ .acct-snippet pre {
242
+ background: var(--bg); border: 1px solid var(--panel-border); border-radius: 4px;
243
+ padding: 8px; margin: 4px 0;
244
+ font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
245
+ white-space: pre-wrap; word-break: break-all;
246
+ max-height: 200px; overflow-y: auto;
247
+ }
248
+ .acct-link {
249
+ display: block; width: 100%; text-align: left;
250
+ background: transparent; border: none; border-radius: 4px;
251
+ color: var(--text); font: inherit; font-size: 13px;
252
+ padding: 6px 8px; cursor: pointer;
253
+ }
254
+ .acct-link:hover { background: rgba(128,128,128,0.15); }
255
+ .acct-link.acct-danger:hover { color: var(--danger); }
194
256
  </style>
195
257
  </head>
196
258
  <body>
@@ -325,28 +387,43 @@
325
387
  }
326
388
 
327
389
  // =========================================================================
328
- // Account identity in the nav — GET /account (the lightweight endpoint the
329
- // dashboard payload also carries). Fail-soft: 401/fetch error leaves the
330
- // span empty (the main load() owns the 401 token prompt); an all-null
331
- // account (self-hosted default) renders nothing. Built with textContent
332
- // only — this page never uses innerHTML for server-derived values.
390
+ // Account identity in the nav + the ACCOUNT MENU (#365) — GET /account (the
391
+ // lightweight endpoint the dashboard payload also carries). Fail-soft:
392
+ // 401/fetch error leaves the span empty (the main load() owns the 401 token
393
+ // prompt); an all-null account (self-hosted default) renders nothing no
394
+ // menu either. Built with createElement/textContent only — this page never
395
+ // uses innerHTML for server-derived values.
333
396
  // =========================================================================
334
397
  function renderAccount(a) {
335
398
  var host = document.getElementById("nav-account");
336
399
  if (!host) return;
337
400
  host.textContent = "";
338
401
  if (!a || (a.name === null && a.org === null && a.plan === null)) return;
402
+ var trigger = document.createElement("button");
403
+ trigger.type = "button";
404
+ trigger.id = "acct-trigger";
405
+ trigger.className = "acct-trigger";
406
+ trigger.setAttribute("aria-haspopup", "menu");
407
+ trigger.setAttribute("aria-expanded", "false");
339
408
  var who = [a.name, a.org].filter(function (s) { return typeof s === "string" && s !== ""; });
340
409
  who.forEach(function (s, i) {
341
- if (i > 0) host.appendChild(document.createTextNode(" · "));
342
- host.appendChild(document.createTextNode(s));
410
+ if (i > 0) trigger.appendChild(document.createTextNode(" · "));
411
+ trigger.appendChild(document.createTextNode(s));
343
412
  });
344
413
  if (typeof a.plan === "string" && a.plan !== "") {
345
414
  var pill = document.createElement("span");
346
415
  pill.className = "plan";
347
416
  pill.textContent = a.plan;
348
- host.appendChild(pill);
417
+ trigger.appendChild(pill);
349
418
  }
419
+ var caret = document.createElement("span");
420
+ caret.className = "acct-caret";
421
+ caret.setAttribute("aria-hidden", "true");
422
+ caret.textContent = "▾";
423
+ trigger.appendChild(caret);
424
+ host.appendChild(trigger);
425
+ host.appendChild(buildAcctMenu());
426
+ wireAcctMenu();
350
427
  }
351
428
  function loadAccount() {
352
429
  fetch("/account", { headers: authHeaders() })
@@ -355,6 +432,270 @@
355
432
  .catch(function () { /* leave the span empty — nav is never worth an error */ });
356
433
  }
357
434
 
435
+ // =========================================================================
436
+ // Account menu (#365) — dropdown under the nav account element: token
437
+ // reveal/copy, connect snippets, plan & billing, sign-out. The token shown
438
+ // is AUTHORITATIVE server truth: a lazy GET /account/token on the FIRST
439
+ // open (echo-only endpoint — the fetch already proves the caller holds the
440
+ // token). localStorage is NEVER trusted for the display: it can be stale
441
+ // after token rotation and is absent entirely for browser sessions the
442
+ // hosted router authenticates via its session→bearer injection. On fetch
443
+ // failure the row fails explicitly ("token unavailable", actions disabled).
444
+ // Built with createElement/textContent only (page convention).
445
+ // =========================================================================
446
+ var acctToken = null; // token from /account/token (server truth)
447
+ var acctTokenState = "unfetched"; // "unfetched" | "ok" | "unavailable"
448
+ var acctTokenFetched = false; // one lazy fetch attempt per page load
449
+ var acctRevealed = false; // unmasked while the menu is open
450
+
451
+ function maskToken(t) {
452
+ // hctx-••••••23f9 — first 5 chars + 6 bullets + last 4. Real tokens are
453
+ // hctx-<32hex>; the guard keeps a degenerate short token from leaking.
454
+ if (typeof t !== "string" || t.length < 10) return "••••••••••";
455
+ return t.slice(0, 5) + "••••••" + t.slice(-4);
456
+ }
457
+
458
+ function el(tag, id, cls, text) {
459
+ var n = document.createElement(tag);
460
+ if (id) n.id = id;
461
+ if (cls) n.className = cls;
462
+ if (text !== undefined) n.textContent = text;
463
+ return n;
464
+ }
465
+
466
+ // Static menu skeleton — token-dependent values fill in renderAcctToken().
467
+ function buildAcctMenu() {
468
+ var menu = el("div", "acct-menu", "acct-menu");
469
+ menu.appendChild(el("div", null, "acct-label", "Connection token"));
470
+ var row = el("div", null, "acct-token-row");
471
+ row.appendChild(el("code", "acct-token-val", "acct-token", "…"));
472
+ row.appendChild(el("button", "acct-reveal", "acct-btn", "Reveal"));
473
+ row.appendChild(el("button", "acct-copy-token", "acct-btn", "Copy"));
474
+ menu.appendChild(row);
475
+ var details = el("details", "acct-connect");
476
+ details.appendChild(el("summary", null, null, "Connect an agent"));
477
+ details.appendChild(el("div", null, "acct-label", "CLI"));
478
+ var snip1 = el("div", null, "acct-snippet");
479
+ snip1.appendChild(el("pre", "acct-cli"));
480
+ snip1.appendChild(el("button", "acct-copy-cli", "acct-btn", "Copy"));
481
+ details.appendChild(snip1);
482
+ details.appendChild(el("div", null, "acct-label", "MCP — ~/.claude.json"));
483
+ var snip2 = el("div", null, "acct-snippet");
484
+ snip2.appendChild(el("pre", "acct-mcp"));
485
+ snip2.appendChild(el("button", "acct-copy-mcp", "acct-btn", "Copy"));
486
+ details.appendChild(snip2);
487
+ menu.appendChild(details);
488
+ menu.appendChild(el("button", "acct-plan", "acct-link", "Plan & billing"));
489
+ menu.appendChild(el("button", "acct-signout", "acct-link acct-danger", "Sign out"));
490
+ // All menu buttons are type=button (never submit).
491
+ var btns = menu.querySelectorAll("button");
492
+ for (var i = 0; i < btns.length; i++) btns[i].type = "button";
493
+ return menu;
494
+ }
495
+
496
+ // The connect line: server URL + token on ONE line, space-separated.
497
+ function acctConnectLine() {
498
+ return location.origin + " " + acctToken;
499
+ }
500
+
501
+ // CLI snippet — init has NO --token flag; it prompts interactively. The
502
+ // comment mirrors init.ts's own printed hint ("When prompted for the token,
503
+ // paste: …").
504
+ function acctCliText() {
505
+ return "# When prompted for the token, paste: " + acctToken +
506
+ "\nnpx @gamaze/hicortex init --server " + location.origin;
507
+ }
508
+
509
+ // MCP snippet — mirrors EXACTLY what runClientInit writes into
510
+ // ~/.claude.json (init.ts): SSE transport at <origin>/sse with a bearer
511
+ // Authorization header.
512
+ function acctMcpText() {
513
+ return JSON.stringify({
514
+ mcpServers: {
515
+ hicortex: {
516
+ type: "sse",
517
+ url: location.origin + "/sse",
518
+ headers: { Authorization: "Bearer " + acctToken }
519
+ }
520
+ }
521
+ }, null, 2);
522
+ }
523
+
524
+ function renderAcctToken() {
525
+ var val = document.getElementById("acct-token-val");
526
+ if (!val) return; // menu not rendered (self-hosted all-null account)
527
+ var revealBtn = document.getElementById("acct-reveal");
528
+ var copyToken = document.getElementById("acct-copy-token");
529
+ var copyCli = document.getElementById("acct-copy-cli");
530
+ var copyMcp = document.getElementById("acct-copy-mcp");
531
+ var cli = document.getElementById("acct-cli");
532
+ var mcp = document.getElementById("acct-mcp");
533
+ if (acctTokenState !== "ok") {
534
+ val.textContent = acctTokenState === "unfetched" ? "…" : "token unavailable";
535
+ revealBtn.disabled = true;
536
+ copyToken.disabled = true;
537
+ copyCli.disabled = true;
538
+ copyMcp.disabled = true;
539
+ cli.textContent = acctTokenState === "unfetched" ? "" : "token unavailable";
540
+ mcp.textContent = acctTokenState === "unfetched" ? "" : "token unavailable";
541
+ return;
542
+ }
543
+ revealBtn.disabled = false;
544
+ copyToken.disabled = false;
545
+ copyCli.disabled = false;
546
+ copyMcp.disabled = false;
547
+ val.textContent = acctRevealed ? acctToken : maskToken(acctToken);
548
+ revealBtn.textContent = acctRevealed ? "Hide" : "Reveal";
549
+ cli.textContent = acctCliText();
550
+ mcp.textContent = acctMcpText();
551
+ }
552
+
553
+ function fetchAcctToken() {
554
+ if (acctTokenFetched) return;
555
+ acctTokenFetched = true;
556
+ fetch("/account/token", { headers: authHeaders() })
557
+ .then(function (resp) {
558
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
559
+ return resp.json();
560
+ })
561
+ .then(function (data) {
562
+ if (typeof data.token !== "string" || !data.token) throw new Error("bad payload");
563
+ acctToken = data.token;
564
+ acctTokenState = "ok";
565
+ renderAcctToken();
566
+ })
567
+ .catch(function () {
568
+ acctTokenState = "unavailable"; // fail explicitly — no localStorage fallback
569
+ renderAcctToken();
570
+ });
571
+ }
572
+
573
+ function closeAcctMenu() {
574
+ var menu = document.getElementById("acct-menu");
575
+ if (!menu) return;
576
+ menu.classList.remove("open");
577
+ var trigger = document.getElementById("acct-trigger");
578
+ if (trigger) trigger.setAttribute("aria-expanded", "false");
579
+ // Masked again + connect section collapsed on EVERY close (locked design).
580
+ acctRevealed = false;
581
+ var details = document.getElementById("acct-connect");
582
+ if (details) details.removeAttribute("open");
583
+ renderAcctToken();
584
+ }
585
+
586
+ function openAcctMenu() {
587
+ var menu = document.getElementById("acct-menu");
588
+ if (!menu) return;
589
+ menu.classList.add("open");
590
+ document.getElementById("acct-trigger").setAttribute("aria-expanded", "true");
591
+ fetchAcctToken(); // lazy — one attempt, on first open only
592
+ }
593
+
594
+ // Clipboard helper: async API when available, temporary <textarea> +
595
+ // execCommand fallback (http remote origins lack the async clipboard API).
596
+ // Brief "Copied" feedback (label swap for ~1.5 s); total failure alerts.
597
+ function copyToClipboard(text, btn) {
598
+ var flash = function () {
599
+ var orig = btn.dataset.origLabel || (btn.dataset.origLabel = btn.textContent);
600
+ btn.textContent = "Copied";
601
+ clearTimeout(btn._copiedTimer);
602
+ btn._copiedTimer = setTimeout(function () { btn.textContent = orig; }, 1500);
603
+ };
604
+ var legacy = function () {
605
+ try {
606
+ var ta = document.createElement("textarea");
607
+ ta.value = text;
608
+ ta.style.position = "fixed";
609
+ ta.style.opacity = "0";
610
+ document.body.appendChild(ta);
611
+ ta.select();
612
+ var ok = document.execCommand("copy");
613
+ document.body.removeChild(ta);
614
+ return ok;
615
+ } catch (e) { return false; }
616
+ };
617
+ var fallback = function () {
618
+ if (legacy()) { flash(); return; }
619
+ alert("Copy failed — select the text and copy it manually.");
620
+ };
621
+ if (navigator.clipboard && navigator.clipboard.writeText) {
622
+ navigator.clipboard.writeText(text).then(flash, fallback);
623
+ } else {
624
+ fallback();
625
+ }
626
+ }
627
+
628
+ // Sign-out is CLIENT-SIDE ONLY (#365): there is no server session to
629
+ // invalidate in the Hicortex server itself — auth is per-request bearer, so
630
+ // dropping the locally stored tokens IS the sign-out. (The hosted router
631
+ // separately keeps a Google session cookie and offers /auth/logout — out of
632
+ // scope here; this only ends the browser's console session.)
633
+ function signOut() {
634
+ try {
635
+ localStorage.removeItem("hicortexToken");
636
+ localStorage.removeItem("hicortex-dashboard-token");
637
+ } catch (e) { /* private mode — reload anyway */ }
638
+ location.reload(); // back to the unauthenticated 401-prompt state
639
+ }
640
+
641
+ function wireAcctMenu() {
642
+ var trigger = document.getElementById("acct-trigger");
643
+ if (!trigger) return;
644
+ trigger.addEventListener("click", function () {
645
+ var menu = document.getElementById("acct-menu");
646
+ if (menu && menu.classList.contains("open")) closeAcctMenu();
647
+ else openAcctMenu();
648
+ });
649
+ document.getElementById("acct-reveal").addEventListener("click", function () {
650
+ acctRevealed = !acctRevealed;
651
+ renderAcctToken();
652
+ });
653
+ var copyToken = document.getElementById("acct-copy-token");
654
+ copyToken.addEventListener("click", function () {
655
+ if (acctTokenState === "ok") copyToClipboard(acctConnectLine(), copyToken);
656
+ });
657
+ var copyCli = document.getElementById("acct-copy-cli");
658
+ copyCli.addEventListener("click", function () {
659
+ if (acctTokenState === "ok") {
660
+ copyToClipboard("npx @gamaze/hicortex init --server " + location.origin, copyCli);
661
+ }
662
+ });
663
+ var copyMcp = document.getElementById("acct-copy-mcp");
664
+ copyMcp.addEventListener("click", function () {
665
+ if (acctTokenState === "ok") copyToClipboard(acctMcpText(), copyMcp);
666
+ });
667
+ // Plan & billing lives on the dashboard page — navigate there (append
668
+ // ?token= when one is known, mirroring the shared-nav link handler) and
669
+ // land on the digest panel's budget/usage anchor (#371 lands there).
670
+ document.getElementById("acct-plan").addEventListener("click", function () {
671
+ var t = null;
672
+ try {
673
+ t = token
674
+ || localStorage.getItem("hicortexToken")
675
+ || localStorage.getItem("hicortex-dashboard-token");
676
+ } catch (e) { /* private mode — navigate without a token */ }
677
+ var u = new URL("/dashboard", location.origin);
678
+ if (t) u.searchParams.set("token", t);
679
+ u.hash = "plan-billing";
680
+ location.href = u.toString();
681
+ });
682
+ document.getElementById("acct-signout").addEventListener("click", signOut);
683
+ renderAcctToken();
684
+ }
685
+
686
+ // Click-outside + Escape close the menu (wired once; the handlers no-op
687
+ // when the menu isn't rendered).
688
+ document.addEventListener("click", function (e) {
689
+ var menu = document.getElementById("acct-menu");
690
+ if (!menu || !menu.classList.contains("open")) return;
691
+ var wrap = document.getElementById("nav-account");
692
+ if (wrap && wrap.contains(e.target)) return;
693
+ closeAcctMenu();
694
+ });
695
+ window.addEventListener("keydown", function (e) {
696
+ if (e.key === "Escape") closeAcctMenu();
697
+ });
698
+
358
699
  // =========================================================================
359
700
  // DOM refs
360
701
  // =========================================================================