shadok-ai 0.17.35 → 0.17.36

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 (2) hide show
  1. package/package.json +1 -1
  2. package/public/index.html +106 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.17.35",
3
+ "version": "0.17.36",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test --test-timeout=120000 test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -1086,8 +1086,28 @@
1086
1086
  .sec-meta { flex-basis: 100%; font-size: 11px; color: var(--text-dim); margin-top: 2px; }
1087
1087
  #secretAttach[data-grouped="1"] { display: none; }
1088
1088
  .sec-from { margin-left: 8px; font-size: 11px; color: var(--text-dim); font-family: var(--sans, inherit); }
1089
- .sec-ctl { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; font-size: 12px; margin-top: 4px; }
1090
- .sec-ctl label { display: inline-flex; gap: 4px; align-items: center; cursor: pointer; }
1089
+ /* Grouped mode: each own secret is a card — name + actions, one meta line, then
1090
+ the "Shared with" / "Injected into" chip rows and ONE Save. */
1091
+ #secretsList li.sec-row { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; background: none; }
1092
+ #secretsList li.sec-row:nth-child(odd) { background: none; }
1093
+ .sec-ctl { flex-basis: 100%; display: flex; flex-direction: column; gap: 8px; font-size: 12px; margin-top: 10px; }
1094
+ .sec-field { display: grid; grid-template-columns: 96px 1fr; gap: 8px; align-items: start; }
1095
+ .sec-field-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; padding-top: 5px; }
1096
+ .sec-chips { display: flex; flex-wrap: wrap; gap: 6px; }
1097
+ .sec-chip { position: relative; display: inline-flex; align-items: center; gap: 5px; font-size: 12px; padding: 3px 10px; border: 1px solid var(--line); border-radius: 14px; cursor: pointer; user-select: none; }
1098
+ .sec-chip:hover { border-color: var(--amber); }
1099
+ .sec-chip input { position: absolute; opacity: 0; pointer-events: none; }
1100
+ .sec-chip:has(input:checked) { border-color: var(--amber); background: color-mix(in srgb, var(--amber) 16%, transparent); color: var(--text); }
1101
+ .sec-chip:has(input:checked)::before { content: "✓"; font-size: 11px; color: var(--amber); }
1102
+ .sec-chip:has(input:focus-visible) { outline: 2px solid var(--amber); outline-offset: 1px; }
1103
+ .sec-foot { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
1104
+ .sec-status { flex: 1; font-size: 12px; color: var(--text-dim); }
1105
+ .sec-status.err { color: #e2564a; }
1106
+ #secretsList button.sec-save { font-size: 12px; padding: 4px 14px; border-radius: 6px; border: 1px solid var(--amber); color: var(--amber); }
1107
+ #secretsList button.sec-save:hover:not(:disabled) { background: color-mix(in srgb, var(--amber) 15%, transparent); }
1108
+ #secretsList button.sec-save:disabled { opacity: .35; cursor: default; border-color: var(--line); color: var(--text-dim); }
1109
+ .sec-err { margin: 0 0 10px; padding: 8px 11px; border: 1px solid #e2564a; border-radius: 8px; color: #e2564a; background: color-mix(in srgb, #e2564a 8%, transparent); font-size: 12.5px; }
1110
+ @media (max-width: 520px) { .sec-field { grid-template-columns: 1fr; gap: 4px; } .sec-field-label { padding-top: 0; } }
1091
1111
  .group-head .gkey { flex: none; font-size: 12px; padding: 0 4px; cursor: pointer; visibility: hidden; }
1092
1112
  .group-head:hover .gkey { visibility: visible; }
1093
1113
  /* A touch screen has no hover, so the group-head ⚙/🔑 (the single-column /
@@ -3017,6 +3037,7 @@
3017
3037
  </div>
3018
3038
  <div class="pin-body">
3019
3039
  <div id="secretsScope" class="sec-scope" hidden><span>Group</span><select id="secretsGroup" aria-label="Group whose secrets are shown"></select></div>
3040
+ <p id="secretsErr" class="sec-err" role="alert" hidden></p>
3020
3041
  <ul id="secretsList"></ul>
3021
3042
  <form id="secretsAdd">
3022
3043
  <div class="sec-add-row">
@@ -10504,8 +10525,11 @@
10504
10525
  actions.append(
10505
10526
  actionBtn("s-edit", "✎", "Update this secret's value / the profiles it feeds", () => startEditSecret(k)),
10506
10527
  actionBtn("s-del", "✕", "Remove from vault", async () => {
10507
- const g = secretWriteGroup();
10508
- await fetch("/secrets?name=" + encodeURIComponent(k) + (g ? "&group=" + encodeURIComponent(g) : ""), { method: "DELETE" });
10528
+ // The row's OWNER group, not the panel's write target (see secretControls).
10529
+ const g = grouped && sec.ownerGroup != null ? String(sec.ownerGroup) : secretWriteGroup();
10530
+ const r = await fetch("/secrets?name=" + encodeURIComponent(k) + (g ? "&group=" + encodeURIComponent(g) : ""), { method: "DELETE" });
10531
+ const e = await vaultError(r);
10532
+ if (e) { showVaultError("Could not remove " + k + ": " + e); return; }
10509
10533
  if (editingSecret === k) resetSecretForm();
10510
10534
  await loadVault(); renderSecrets();
10511
10535
  }),
@@ -10519,36 +10543,89 @@
10519
10543
  }
10520
10544
  return li;
10521
10545
  }
10546
+ /** Read a refused vault write's message: the server answers `{error}` JSON;
10547
+ * a proxy may answer HTML, so fall back to the status line. */
10548
+ async function vaultError(r) {
10549
+ if (r.ok) return null;
10550
+ try { const j = await r.json(); if (j && j.error) return String(j.error); } catch {}
10551
+ return "refused (HTTP " + r.status + ")";
10552
+ }
10522
10553
  /** Own secret, grouped mode: who it is lent to (PUT /secrets/share) and which
10523
- * profiles inject it (PUT /secrets/profiles). Each saves on its own button so
10524
- * a half-ticked state never reaches the server. */
10554
+ * profiles inject it (PUT /secrets/profiles), edited as chips and saved by ONE
10555
+ * button that only lights up once something changed. Both calls target the
10556
+ * secret's OWNER group, never the panel's selection: in the "Public groups"
10557
+ * view the panel's write target is the first public group, which sent every
10558
+ * row there (a 403 or a 404 for a secret living in another public group). A
10559
+ * refusal is shown in the card, not swallowed. */
10525
10560
  function secretControls(sec) {
10526
10561
  const box = document.createElement("div"); box.className = "sec-ctl";
10527
10562
  const targets = window.shareTargets ? window.shareTargets(vaultGroups, sec.ownerGroup) : [];
10528
10563
  const profs = profileCache.map((p) => p.name).filter((n) => !(window.isManagedProfile && window.isManagedProfile(n))).sort();
10529
- const picker = (label, options, current, onSave) => {
10530
- if (!options.length) return;
10531
- const wrap = document.createElement("span");
10532
- wrap.append(label + ": ");
10564
+ const same = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
10565
+ const fields = [];
10566
+ const field = (label, hint, options, current) => {
10567
+ if (!options.length) return null;
10568
+ const row = document.createElement("div"); row.className = "sec-field";
10569
+ const l = document.createElement("span"); l.className = "sec-field-label"; l.textContent = label; l.title = hint;
10570
+ const chips = document.createElement("div"); chips.className = "sec-chips";
10571
+ const initial = (current || []).map(String);
10533
10572
  const cbs = options.map((o) => {
10534
- const l = document.createElement("label");
10535
- const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = o.value; cb.checked = (current || []).includes(o.value);
10573
+ const lab = document.createElement("label"); lab.className = "sec-chip";
10574
+ const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = o.value; cb.checked = initial.includes(o.value);
10536
10575
  const t = document.createElement("span"); t.textContent = o.label;
10537
- l.append(cb, t); wrap.appendChild(l); return cb;
10576
+ lab.append(cb, t); chips.appendChild(lab); return cb;
10538
10577
  });
10539
- wrap.appendChild(actionBtn("linkish", "save", "Save", async () => {
10540
- await onSave(cbs.filter((c) => c.checked).map((c) => c.value));
10541
- await loadVault(); renderSecrets();
10542
- }));
10543
- box.appendChild(wrap);
10578
+ row.append(l, chips); box.appendChild(row);
10579
+ const f = { cbs, initial: initial.filter((v) => options.some((o) => o.value === v)), value: () => cbs.filter((c) => c.checked).map((c) => c.value) };
10580
+ fields.push(f);
10581
+ return f;
10544
10582
  };
10545
- // A secret's own controls target its OWNER group (the section it lives in).
10546
- picker("shared read with", targets.map((g) => ({ value: String(g.id), label: g.name })), sec.sharedWith, (sharedWith) =>
10547
- fetch("/secrets/share", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name: sec.name, sharedWith })) }));
10548
- picker("attach to profiles", profs.map((n) => ({ value: n, label: n })), sec.profiles, (profiles) =>
10549
- fetch("/secrets/profiles", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name: sec.name, profiles })) }));
10583
+ const share = field("Shared with", "Groups that may READ this secret (it stays owned by its group)",
10584
+ targets.map((g) => ({ value: String(g.id), label: g.name })), sec.sharedWith);
10585
+ const inject = field("Injected into", "Profiles whose agents receive it as an environment variable at spawn",
10586
+ profs.map((n) => ({ value: n, label: n })), sec.profiles);
10587
+ if (!fields.length) return box;
10588
+ const foot = document.createElement("div"); foot.className = "sec-foot";
10589
+ const status = document.createElement("span"); status.className = "sec-status"; status.setAttribute("role", "status");
10590
+ const save = document.createElement("button"); save.type = "button"; save.className = "sec-save"; save.textContent = "Save"; save.disabled = true;
10591
+ const dirty = () => fields.some((f) => !same(f.value(), f.initial));
10592
+ box.addEventListener("change", () => {
10593
+ save.disabled = !dirty();
10594
+ if (status.classList.contains("ok")) { status.textContent = ""; status.className = "sec-status"; }
10595
+ });
10596
+ save.addEventListener("click", async () => {
10597
+ save.disabled = true; status.className = "sec-status"; status.textContent = "Saving…";
10598
+ const put = (url, body) => fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" },
10599
+ body: JSON.stringify({ name: sec.name, group: String(sec.ownerGroup), ...body }) });
10600
+ const errors = [];
10601
+ try {
10602
+ if (share && !same(share.value(), share.initial)) {
10603
+ const e = await vaultError(await put("/secrets/share", { sharedWith: share.value() }));
10604
+ if (e) errors.push("Sharing not saved: " + e);
10605
+ }
10606
+ if (inject && !same(inject.value(), inject.initial)) {
10607
+ const e = await vaultError(await put("/secrets/profiles", { profiles: inject.value() }));
10608
+ if (e) errors.push("Profiles not saved: " + e);
10609
+ }
10610
+ } catch (err) { errors.push("Network error: " + (err && err.message || err)); }
10611
+ if (errors.length) {
10612
+ // Keep the user's ticks on screen so they can retry or undo — a reload
10613
+ // here would silently reset them to the server's state.
10614
+ status.className = "sec-status err"; status.textContent = errors.join(" · ");
10615
+ save.disabled = false;
10616
+ return;
10617
+ }
10618
+ await loadVault(); renderSecrets();
10619
+ });
10620
+ foot.append(status, save); box.appendChild(foot);
10550
10621
  return box;
10551
10622
  }
10623
+ /** Panel-level refusal (add / update / delete): shown above the list until the
10624
+ * next successful write or the panel is reopened. */
10625
+ function showVaultError(msg) {
10626
+ const el = $("secretsErr");
10627
+ el.textContent = msg || ""; el.hidden = !msg;
10628
+ }
10552
10629
  function renderSecrets() {
10553
10630
  const ul = $("secretsList");
10554
10631
  ul.innerHTML = "";
@@ -10689,6 +10766,7 @@
10689
10766
  await Promise.all([loadVault(), loadProfilesInto()]);
10690
10767
  renderSecrets();
10691
10768
  resetSecretForm();
10769
+ showVaultError("");
10692
10770
  $("secretsReload").hidden = true;
10693
10771
  $("secretsOverlay").hidden = false;
10694
10772
  $("secretKey").focus();
@@ -12024,8 +12102,12 @@
12024
12102
  // AGENT replacing a credential silently).
12025
12103
  const keepValue = editingSecret && value === "";
12026
12104
  if (!keepValue) {
12027
- await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name, value, overwrite: true })) });
12105
+ const r = await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name, value, overwrite: true })) });
12106
+ const err = await vaultError(r);
12107
+ // Refused: say so and keep the form filled, rather than clearing it as if saved.
12108
+ if (err) { showVaultError("Could not save " + name + ": " + err); return; }
12028
12109
  }
12110
+ showVaultError("");
12029
12111
  // Grouped mode binds profiles per row (PUT /secrets/profiles) and hides the
12030
12112
  // ticks: syncing here would read an empty selection and detach everything.
12031
12113
  if (window.secretsPanelMode(vaultGroups) === "flat") await syncSecretToProfiles(name, wanted); // refreshes profileCache