@monster-orange/den 0.4.2 → 0.4.3

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.
@@ -16,6 +16,7 @@
16
16
  * (.den-sec, .den-btn, .den-dot) are reused as they are.
17
17
  */
18
18
  import { requireUnlock } from "/topbar.js";
19
+ import { fillStartForm } from "/agent-start.js";
19
20
 
20
21
  const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
21
22
  const esc = (s) =>
@@ -62,6 +63,8 @@ export function agentsSectionHtml(ws) {
62
63
  <form class="agent-form" id="agent-start-form" hidden>
63
64
  <label>${esc(t("ag.persona"))} <select class="form-sel" name="persona" required></select></label>
64
65
  <label>${esc(t("ag.runtime"))} <select class="form-sel" name="runtime"></select></label>
66
+ <p class="agent-form-help" data-persona-help hidden></p>
67
+ <p class="agent-form-ready" data-runtime-ready role="status" hidden></p>
65
68
  <button type="submit" class="den-btn sm primary" id="agent-start-go">${esc(t("ag.go"))}</button>
66
69
  <button type="button" class="den-btn sm" data-agent-cancel>${esc(t("ag.cancel"))}</button>
67
70
  </form>
@@ -185,22 +188,7 @@ async function refresh(ws) {
185
188
  }
186
189
 
187
190
  function fillForm(form, data) {
188
- const ps = form.querySelector('select[name="persona"]');
189
- ps.innerHTML = (data.personas ?? [])
190
- .map(
191
- (p) =>
192
- `<option value="${esc(p.id)}">${esc(p.id)}${p.role ? ` — ${esc(p.role)}` : ""}</option>`,
193
- )
194
- .join("");
195
- const rs = form.querySelector('select[name="runtime"]');
196
- rs.innerHTML =
197
- `<option value="">${esc(t("ag.runtime.auto"))}</option>` +
198
- (data.runtimes ?? [])
199
- .map(
200
- (r) =>
201
- `<option value="${esc(r.id)}"${r.available ? "" : " disabled"}>${esc(r.id)}${r.available ? "" : ` — ${esc(t("ag.unavailable"))}`}</option>`,
202
- )
203
- .join("");
191
+ fillStartForm(form, data, t);
204
192
  }
205
193
 
206
194
  async function openForm(sec) {
@@ -229,7 +217,7 @@ async function openForm(sec) {
229
217
  }
230
218
 
231
219
  async function act(ws, verb, id, button) {
232
- if (!requireUnlock()) return;
220
+ if (!(await requireUnlock())) return;
233
221
  button.disabled = true;
234
222
  try {
235
223
  await api("POST", `${base(ws)}/${encodeURIComponent(id)}/${verb}`);
@@ -243,7 +231,7 @@ async function act(ws, verb, id, button) {
243
231
  }
244
232
 
245
233
  async function start(ws, form) {
246
- if (!requireUnlock()) return;
234
+ if (!(await requireUnlock())) return;
247
235
  const go = form.querySelector("#agent-start-go");
248
236
  const persona = form.querySelector('select[name="persona"]').value;
249
237
  const runtime = form.querySelector('select[name="runtime"]').value;
@@ -311,8 +311,56 @@
311
311
  }
312
312
  .ag-dlg form {
313
313
  display: grid;
314
+ /* AG-10.0: minmax(0, 1fr), so a long option can't widen the form
315
+ past the dialog and push Cancel and Start out of sight. */
316
+ grid-template-columns: minmax(0, 1fr);
314
317
  gap: 12px;
315
318
  }
319
+ .ag-dlg .help {
320
+ margin: -6px 0 0;
321
+ font: 12.5px/1.45 var(--sans);
322
+ color: var(--muted);
323
+ overflow-wrap: anywhere;
324
+ }
325
+ .ag-dlg .help[hidden],
326
+ .ag-dlg .ready[hidden],
327
+ .ag-dlg .err[hidden],
328
+ .ag-dlg .ag-lock[hidden] {
329
+ display: none;
330
+ }
331
+ /* AG-0.2: the runtime it will run, or why it can't, before Start
332
+ (the mock's .helpl: 12px, faint, 1.4). */
333
+ .ag-dlg .ready {
334
+ margin: -6px 0 0;
335
+ font: 12px/1.4 var(--sans);
336
+ color: var(--faint);
337
+ overflow-wrap: anywhere;
338
+ }
339
+ .ag-dlg .ready[data-tone="warn"] {
340
+ color: var(--warn);
341
+ }
342
+ .ag-dlg .ready .mono {
343
+ font-family: var(--mono);
344
+ }
345
+ /* LK-1: locked, the password is asked here, at the top of the dialog,
346
+ and Start unlocks then starts. */
347
+ .ag-dlg .ag-lock {
348
+ display: grid;
349
+ gap: 6px;
350
+ padding: 10px 12px;
351
+ border: 1px solid
352
+ color-mix(in srgb, var(--warn) 45%, var(--border));
353
+ border-radius: var(--radius-button);
354
+ background: color-mix(in srgb, var(--warn) 8%, var(--raised));
355
+ }
356
+ .ag-dlg .ag-lock .help {
357
+ margin: 0;
358
+ }
359
+ .ag-dlg .err {
360
+ margin: 0;
361
+ font: 12.5px/1.45 var(--sans);
362
+ color: var(--bad);
363
+ }
316
364
  .ag-dlg h2 {
317
365
  margin: 0;
318
366
  font: 600 17px var(--display);
@@ -325,7 +373,10 @@
325
373
  text-transform: uppercase;
326
374
  letter-spacing: 0.07em;
327
375
  }
376
+ .ag-dlg input[type="password"],
328
377
  .ag-dlg select {
378
+ width: 100%;
379
+ min-width: 0;
329
380
  font: 13px var(--sans);
330
381
  text-transform: none;
331
382
  letter-spacing: 0;
@@ -396,6 +447,17 @@
396
447
  <dialog class="ag-dlg" id="ag-dlg" aria-labelledby="ag-dlg-h">
397
448
  <form method="dialog" id="ag-form">
398
449
  <h2 id="ag-dlg-h">Start an agent</h2>
450
+ <div class="ag-lock" id="ag-lock" hidden>
451
+ <label
452
+ ><span id="ag-lock-t">Admin password</span>
453
+ <input
454
+ type="password"
455
+ id="ag-lock-pw"
456
+ autocomplete="current-password"
457
+ /></label>
458
+ <p class="help" id="ag-lock-note"></p>
459
+ <p class="err" id="ag-lock-err" role="alert" hidden></p>
460
+ </div>
399
461
  <label id="ag-ws-l"
400
462
  ><span id="ag-ws-t">Workspace</span>
401
463
  <select name="ws" id="ag-ws"></select
@@ -404,10 +466,18 @@
404
466
  ><span id="ag-p-t">Persona</span>
405
467
  <select name="persona" id="ag-persona" required></select
406
468
  ></label>
469
+ <p class="help" id="ag-p-help" data-persona-help hidden></p>
407
470
  <label
408
471
  ><span id="ag-r-t">Runtime</span>
409
472
  <select name="runtime" id="ag-runtime"></select
410
473
  ></label>
474
+ <p
475
+ class="ready"
476
+ id="ag-r-ready"
477
+ data-runtime-ready
478
+ role="status"
479
+ hidden
480
+ ></p>
411
481
  <div class="btns">
412
482
  <button type="button" class="den-btn" id="ag-cancel">Cancel</button>
413
483
  <button type="submit" class="den-btn primary" id="ag-go">
@@ -330,6 +330,7 @@
330
330
  "ag.persona": "Persona",
331
331
  "ag.runtime": "Runtime",
332
332
  "ag.runtime.auto": "La de la persona",
333
+ "ag.runtime.default": "{name} (la de la persona)",
333
334
  "ag.unavailable": "no disponible",
334
335
  "ag.go": "Iniciar",
335
336
  "ag.starting": "Iniciando…",
@@ -372,5 +373,15 @@
372
373
  "ag.palStart": "Iniciar agente…",
373
374
  "ag.cap":
374
375
  "Hasta 4 paneles: se cerró el de {id} para hacer sitio (sigue en marcha)",
376
+ "ag.rt.ready": "Inicia {name} {version} desde",
377
+ "ag.rt.readyNoVer": "Inicia {name} desde",
378
+ "ag.rt.notFound": "No se encontró {name}.",
379
+ "ag.rt.searched": "Se buscó en:",
380
+ "ag.rt.more": "y {n} más",
381
+ "ag.rt.nowhere": "ningún directorio",
382
+ "ag.rt.refused": "{name} no puede iniciarse: {why}.",
383
+ "ag.rt.fix":
384
+ "Instálalo, o pon su ruta en {key} en {file}, o ejecuta {cmd}.",
385
+ "ag.rt.unknown": "Aquí no hay ningún runtime {id}.",
375
386
  };
376
387
  })((window.denMessages = window.denMessages || {}));
@@ -318,6 +318,7 @@
318
318
  "ag.persona": "ペルソナ",
319
319
  "ag.runtime": "ランタイム",
320
320
  "ag.runtime.auto": "ペルソナの既定",
321
+ "ag.runtime.default": "{name}(ペルソナの既定)",
321
322
  "ag.unavailable": "利用不可",
322
323
  "ag.go": "開始",
323
324
  "ag.starting": "開始中…",
@@ -357,5 +358,15 @@
357
358
  "ag.palStart": "エージェントを開始…",
358
359
  "ag.cap":
359
360
  "ペインは最大 4 つです。{id} のペインを閉じました(実行は続きます)",
361
+ "ag.rt.ready": "{name} {version} を起動:",
362
+ "ag.rt.readyNoVer": "{name} を起動:",
363
+ "ag.rt.notFound": "{name} が見つかりません。",
364
+ "ag.rt.searched": "検索した場所:",
365
+ "ag.rt.more": "ほか {n} 件",
366
+ "ag.rt.nowhere": "なし",
367
+ "ag.rt.refused": "{name} を起動できません: {why}。",
368
+ "ag.rt.fix":
369
+ "インストールするか、{file} の {key} にパスを設定するか、{cmd} を実行してください。",
370
+ "ag.rt.unknown": "ランタイム {id} はここにありません。",
360
371
  };
361
372
  })((window.denMessages = window.denMessages || {}));
@@ -1371,6 +1371,34 @@
1371
1371
  }
1372
1372
  .agent-form select {
1373
1373
  max-width: 320px;
1374
+ min-width: 0;
1375
+ }
1376
+ .agent-form-help {
1377
+ flex-basis: 100%;
1378
+ margin: 0;
1379
+ font-size: 12px;
1380
+ color: var(--muted);
1381
+ overflow-wrap: anywhere;
1382
+ }
1383
+ .agent-form-help[hidden],
1384
+ .agent-form-ready[hidden] {
1385
+ display: none;
1386
+ }
1387
+ /* AG-0.2: the runtime it will run, or why it can't (the mock's .helpl). */
1388
+ .agent-form-ready {
1389
+ flex-basis: 100%;
1390
+ order: 1;
1391
+ margin: 0;
1392
+ font-size: 12px;
1393
+ line-height: 1.4;
1394
+ color: var(--faint);
1395
+ overflow-wrap: anywhere;
1396
+ }
1397
+ .agent-form-ready[data-tone="warn"] {
1398
+ color: var(--warn);
1399
+ }
1400
+ .agent-form-ready .mono {
1401
+ font-family: var(--mono);
1374
1402
  }
1375
1403
  .agent-cwd {
1376
1404
  max-width: 280px;
@@ -1518,8 +1546,9 @@
1518
1546
  .replace(/"/g, "&quot;");
1519
1547
 
1520
1548
  // ── The lock (R8): the shell's lock chip and popover (topbar.js) ──────────
1521
- // requireSudo() is this page's lock rule, shared with every page: true
1522
- // when unlocked; otherwise the lock popover opens on its password
1549
+ // requireSudo() is this page's lock rule, shared with every page
1550
+ // (async): true when unlocked, after asking the server and the vault
1551
+ // (LK-2, LK-3); otherwise the lock popover opens on its password
1523
1552
  // field and the command doesn't run.
1524
1553
  function requireSudo() {
1525
1554
  return requireUnlock();
@@ -3021,8 +3050,8 @@
3021
3050
 
3022
3051
  // ── Workspace-level command (SSE) ─────────────────────────────────────────
3023
3052
  // Routes output to each service's own tab as `label` events arrive.
3024
- window.runWorkspaceSync = function () {
3025
- if (!requireSudo()) return;
3053
+ window.runWorkspaceSync = async function () {
3054
+ if (!(await requireSudo())) return;
3026
3055
  setAllButtons(true);
3027
3056
  setWsStatus("syncing…");
3028
3057
  const es = new EventSource("/api/workspace/sync");
@@ -3073,8 +3102,8 @@
3073
3102
  // rather than a shared pane, so it sits with everything else for that
3074
3103
  // system. Buttons are not globally disabled: it is one repo.
3075
3104
  const _syncing = new Set();
3076
- window.runRepoSync = function (repo) {
3077
- if (!requireSudo()) return;
3105
+ window.runRepoSync = async function (repo) {
3106
+ if (!(await requireSudo())) return;
3078
3107
  if (_syncing.has(repo)) return; // already in flight — ignore re-entry
3079
3108
  _syncing.add(repo);
3080
3109
 
@@ -3126,8 +3155,8 @@
3126
3155
  };
3127
3156
  };
3128
3157
 
3129
- window.runWorkspaceCmd = function (cmd, addresses) {
3130
- if (!requireSudo()) return;
3158
+ window.runWorkspaceCmd = async function (cmd, addresses) {
3159
+ if (!(await requireSudo())) return;
3131
3160
  const only =
3132
3161
  Array.isArray(addresses) && addresses.length
3133
3162
  ? `&addresses=${encodeURIComponent(addresses.join(","))}`
@@ -3240,8 +3269,8 @@
3240
3269
  // is derived on the SERVER (see the `seed` param in /api/workspace/exec):
3241
3270
  // its rules are load-bearing, and the already-healthy skip it relies on is
3242
3271
  // suppressed whenever an explicit address list is sent.
3243
- window.runUpWithDeps = function (repo) {
3244
- if (!requireSudo()) return;
3272
+ window.runUpWithDeps = async function (repo) {
3273
+ if (!(await requireSudo())) return;
3245
3274
  void runWorkspaceCmdAsync("up", null, {
3246
3275
  seed: repo,
3247
3276
  undismiss: true,
@@ -3252,16 +3281,16 @@
3252
3281
  // same closure as "up with deps", reversed. Derived server-side for the
3253
3282
  // same reason as its counterpart: the already-stopped skip is suppressed
3254
3283
  // when an explicit address list is sent.
3255
- window.runDownWithDeps = function (repo) {
3256
- if (!requireSudo()) return;
3284
+ window.runDownWithDeps = async function (repo) {
3285
+ if (!(await requireSudo())) return;
3257
3286
  void runWorkspaceCmdAsync("down", null, {
3258
3287
  seed: repo,
3259
3288
  undismiss: true,
3260
3289
  });
3261
3290
  };
3262
3291
 
3263
- window.runCmd = function (address, repo, service, cmd) {
3264
- if (!requireSudo()) return;
3292
+ window.runCmd = async function (address, repo, service, cmd) {
3293
+ if (!(await requireSudo())) return;
3265
3294
  // Explicit single-service action — undo any prior dismissal so its pane returns.
3266
3295
  clearDismissed(address);
3267
3296
  // up/restart/down go through the workspace-exec runner: it uses the full
@@ -472,7 +472,7 @@ export const DEN_SETTINGS = [
472
472
  category: "cli",
473
473
  label: "Browser sign-in",
474
474
  description:
475
- "Signing in is needed only when this server is reached beyond this machine (the edge, or a LAN address); on localhost nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
475
+ "Signing in is needed only when this server is reached from another machine (through the edge, or a LAN address); on localhost, and through the edge from this machine, nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
476
476
  keywords: ["sign in", "sign out", "login", "logout", "session", "browser"],
477
477
  scope: "user",
478
478
  control: {
@@ -444,6 +444,7 @@
444
444
  "ag.persona": "Persona",
445
445
  "ag.runtime": "Runtime",
446
446
  "ag.runtime.auto": "The persona's",
447
+ "ag.runtime.default": "{name} (persona default)",
447
448
  "ag.unavailable": "unavailable",
448
449
  "ag.go": "Start",
449
450
  "ag.starting": "Starting…",
@@ -484,6 +485,16 @@
484
485
  "ag.pal": "Agents › {ws} / {name}",
485
486
  "ag.palStart": "Start agent…",
486
487
  "ag.cap": "Up to 4 panes: {id}'s closed to make room (it keeps running)",
488
+ "ag.rt.ready": "Starts {name} {version} from",
489
+ "ag.rt.readyNoVer": "Starts {name} from",
490
+ "ag.rt.notFound": "{name} not found.",
491
+ "ag.rt.searched": "Searched:",
492
+ "ag.rt.more": "and {n} more",
493
+ "ag.rt.nowhere": "no directories",
494
+ "ag.rt.refused": "{name} can't start: {why}.",
495
+ "ag.rt.fix":
496
+ "Install it, or set {key} to its path in {file}, or run {cmd}.",
497
+ "ag.rt.unknown": "No runtime {id} here.",
487
498
  };
488
499
  // The languages the UI has a catalog for, each named in itself (a name is
489
500
  // never translated). The server's list is ui-language.ts's UI_LANGUAGES.
@@ -3361,6 +3372,31 @@
3361
3372
  }
3362
3373
  }
3363
3374
 
3375
+ // 0.4.3: the edge is reachable from other machines (the server fills
3376
+ // data-edge-exposed on #den-shell). One line; dismissed per browser.
3377
+ var EDGE_LS = "den.edgeExposed.dismissed";
3378
+ var edgeDismissed = false;
3379
+ try {
3380
+ edgeDismissed = localStorage.getItem(EDGE_LS) === "1";
3381
+ } catch (e) {
3382
+ edgeDismissed = false;
3383
+ }
3384
+ if (host.dataset.edgeExposed && !edgeDismissed)
3385
+ showBanner("edge-exposed", {
3386
+ text: host.dataset.edgeExposed,
3387
+ action: {
3388
+ label: t("api.close"),
3389
+ run: function () {
3390
+ try {
3391
+ localStorage.setItem(EDGE_LS, "1");
3392
+ } catch (e) {
3393
+ /* no storage: dismissed for this page only */
3394
+ }
3395
+ hideBanner("edge-exposed");
3396
+ },
3397
+ },
3398
+ });
3399
+
3364
3400
  // The config fails validation (0.4.1): the server marks every page it
3365
3401
  // serves while it does (data-config-invalid on #den-shell), and the first
3366
3402
  // /api answer of 422 config_invalid marks it too. From then on the shell
@@ -3817,6 +3853,10 @@
3817
3853
  " max-height: calc(100vh - 72px); overflow-y: auto; box-sizing: border-box;",
3818
3854
  " font: 14px/1.45 var(--sans); color: var(--text); }",
3819
3855
  ".den-pop:focus { outline: none; }",
3856
+ // LK-1: the lock popover is a <dialog> in the top layer (a popover, or
3857
+ // a modal over another modal): placed by its top/left, no UA box.
3858
+ "dialog.den-pop { margin: 0; inset: auto; max-width: none; }",
3859
+ "dialog.den-pop::backdrop { background: transparent; }",
3820
3860
  ".den-pop :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }",
3821
3861
  ".den-pop .den-ml { font: 11px var(--mono); color: var(--faint); text-transform: uppercase;",
3822
3862
  " letter-spacing: .07em; padding: 6px 8px 2px; }",
@@ -15,9 +15,18 @@
15
15
  * form in a popover (Local Dev's sudo banner, moved)
16
16
  *
17
17
  * Exports for pages (through header.js): requireUnlock() (Local Dev's lock
18
- * rule: true when unlocked, else the lock popover opens), isUnlocked(),
19
- * onLockChange(fn), setServiceRunner(fn) (the page that runs Start and
20
- * Restart itself), takePendingAction().
18
+ * rule, async: true when unlocked, else the lock popover opens), and
19
+ * checkUnlock() and unlockWith(password) for a modal that asks inline
20
+ * (LK-1), isUnlocked(), onLockChange(fn), setServiceRunner(fn) (the page
21
+ * that runs Start and Restart itself), takePendingAction().
22
+ *
23
+ * The lock (LK, 0.4.3): the page's own idea of it is never the last word.
24
+ * When it thinks it's locked, an action asks the server again first
25
+ * (another tab or device may have unlocked it, LK-2), then, when a
26
+ * password is stored in the vault, loads it from there (LK-3, once a page,
27
+ * on any page; a page view alone never wakes the vault). The popover is in
28
+ * the top layer (a popover, or its own modal <dialog> when a modal is
29
+ * open), so no modal ever covers it (LK-1).
21
30
  *
22
31
  * The pure parts (statusSummary, statusChip, problemCards, drawerGroups,
23
32
  * checkLine, rankCommands, runOf) take plain data, so tests need no
@@ -745,9 +754,9 @@ export function setServiceRunner(fn) {
745
754
  * stores it for this tab (sessionStorage, not a link anyone could open)
746
755
  * and Local Dev takes it once (takePendingAction).
747
756
  */
748
- export function runServiceAction(cmd, svc) {
757
+ export async function runServiceAction(cmd, svc) {
749
758
  if (serviceRunner) return serviceRunner(cmd, svc);
750
- if (!requireUnlock()) return;
759
+ if (!(await requireUnlock())) return;
751
760
  try {
752
761
  sessionStorage.setItem(
753
762
  SS_PENDING,
@@ -1135,15 +1144,88 @@ export function onLockChange(fn) {
1135
1144
  }
1136
1145
 
1137
1146
  /**
1138
- * Local Dev's lock rule, for every page: true when unlocked; otherwise the
1139
- * lock popover opens on its password field and the action doesn't run.
1147
+ * Whether the lock is open, asking before saying no (never prompts): the
1148
+ * server's status (LK-2), then, when a password is stored, the vault, once
1149
+ * a page (LK-3). Resolves true when unlocked.
1150
+ */
1151
+ let rechecking = null;
1152
+ let vaultTried = false;
1153
+ export function checkUnlock() {
1154
+ if (lock.unlocked) return Promise.resolve(true);
1155
+ rechecking ??= (async () => {
1156
+ const st = await getJson("/api/sudo-password/status", null);
1157
+ if (st && (st.set || st.passwordless)) {
1158
+ setUnlocked(true, !!st.passwordless && !st.set);
1159
+ return true;
1160
+ }
1161
+ if (!vaultTried) {
1162
+ await checkVault();
1163
+ if (
1164
+ !lock.unlocked &&
1165
+ lock.vault.backendAvailable &&
1166
+ lock.vault.hasStored
1167
+ ) {
1168
+ vaultTried = true;
1169
+ const loaded = await getJson(
1170
+ "/api/sudo-password/load-from-vault",
1171
+ { ok: false },
1172
+ { method: "POST" },
1173
+ );
1174
+ if (loaded.ok && !lock.unlocked) setUnlocked(true);
1175
+ }
1176
+ }
1177
+ return lock.unlocked;
1178
+ })().finally(() => {
1179
+ rechecking = null;
1180
+ });
1181
+ return rechecking;
1182
+ }
1183
+
1184
+ /**
1185
+ * Local Dev's lock rule, for every page (async): true when unlocked, after
1186
+ * checkUnlock; otherwise the lock popover opens on its password field and
1187
+ * the action doesn't run.
1140
1188
  */
1141
- export function requireUnlock() {
1142
- if (lock.unlocked) return true;
1189
+ export async function requireUnlock() {
1190
+ if (await checkUnlock()) return true;
1143
1191
  openLock();
1144
1192
  return false;
1145
1193
  }
1146
1194
 
1195
+ /**
1196
+ * Unlock with `password` (the popover's form, or a modal's own field):
1197
+ * { ok, savedToVault?, vaultError?, error? }. On success every page
1198
+ * subscriber hears it (onLockChange). Never logs the password.
1199
+ */
1200
+ export async function unlockWith(password, { saveToVault = false } = {}) {
1201
+ if (!password) return { ok: false, error: t("lock.wrong") };
1202
+ try {
1203
+ const r = await fetch("/api/sudo-password", {
1204
+ method: "POST",
1205
+ headers: { "Content-Type": "application/json" },
1206
+ body: JSON.stringify({ password, saveToVault }),
1207
+ });
1208
+ const data = await r.json().catch(() => ({}));
1209
+ if (r.ok) {
1210
+ if (data.savedToVault) lock.vault.hasStored = true;
1211
+ setUnlocked(true);
1212
+ return {
1213
+ ok: true,
1214
+ savedToVault: !!data.savedToVault,
1215
+ ...(data.vaultError ? { vaultError: data.vaultError } : {}),
1216
+ };
1217
+ }
1218
+ // A wrong password is said in the page's language; another refusal
1219
+ // is the server's own message.
1220
+ return {
1221
+ ok: false,
1222
+ error: r.status === 401 || !data.error ? t("lock.wrong") : data.error,
1223
+ };
1224
+ } catch {
1225
+ return { ok: false, error: t("lock.reqFailed") };
1226
+ }
1227
+ }
1228
+
1147
1229
  function setUnlocked(unlocked, passwordless = false) {
1148
1230
  const changed = lock.unlocked !== unlocked || !lock.known;
1149
1231
  lock.known = true;
@@ -1250,6 +1332,7 @@ async function initLock() {
1250
1332
  await checkVault();
1251
1333
  if (!lock.vault.backendAvailable || !lock.vault.hasStored || lock.unlocked)
1252
1334
  return;
1335
+ vaultTried = true;
1253
1336
  const loaded = await getJson(
1254
1337
  "/api/sudo-password/load-from-vault",
1255
1338
  { ok: false },
@@ -1300,7 +1383,10 @@ function openLock() {
1300
1383
  closeOverlay(false);
1301
1384
  sh()?.closeMenu?.();
1302
1385
  if (!lock.unlocked) void checkVault();
1303
- const pop = document.createElement("div");
1386
+ // LK-1: in the top layer, so no modal covers it. Under an open modal
1387
+ // <dialog> only another modal is reachable (a popover there is inert),
1388
+ // so it opens as its own modal; elsewhere it is a popover.
1389
+ const pop = document.createElement("dialog");
1304
1390
  pop.className = "den-pop den-lock-pop";
1305
1391
  pop.id = "den-lock-pop";
1306
1392
  pop.setAttribute("role", "dialog");
@@ -1335,38 +1421,25 @@ function openLock() {
1335
1421
  err.hidden = true;
1336
1422
  const saveToVault = !!(lock.vault.backendAvailable && saveCb?.checked);
1337
1423
  try {
1338
- const r = await fetch("/api/sudo-password", {
1339
- method: "POST",
1340
- headers: { "Content-Type": "application/json" },
1341
- body: JSON.stringify({ password: pw, saveToVault }),
1342
- });
1343
- const data = await r.json();
1344
- if (r.ok) {
1424
+ const res = await unlockWith(pw, { saveToVault });
1425
+ if (res.ok) {
1345
1426
  input.value = "";
1346
- if (data.savedToVault) lock.vault.hasStored = true;
1347
1427
  forceForm = false;
1348
- setUnlocked(true);
1349
1428
  closeOverlay(true);
1350
1429
  toast(
1351
- saveToVault && data.vaultError
1430
+ saveToVault && res.vaultError
1352
1431
  ? t("lock.saveFailed", {
1353
1432
  s: lock.vault.label || lock.vault.backend,
1354
- err: data.vaultError,
1433
+ err: res.vaultError,
1355
1434
  })
1356
1435
  : t("lock.session"),
1357
1436
  );
1358
1437
  return;
1359
1438
  }
1360
- // A wrong password is said in the page's language; another refusal
1361
- // is the server's own message.
1362
- err.textContent =
1363
- r.status === 401 || !data.error ? t("lock.wrong") : data.error;
1439
+ err.textContent = res.error;
1364
1440
  err.hidden = false;
1365
1441
  input.value = "";
1366
1442
  input.focus();
1367
- } catch {
1368
- err.textContent = t("lock.reqFailed");
1369
- err.hidden = false;
1370
1443
  } finally {
1371
1444
  if (btn.isConnected) {
1372
1445
  btn.textContent = t("lock.go");
@@ -1376,6 +1449,23 @@ function openLock() {
1376
1449
  };
1377
1450
  draw();
1378
1451
  document.body.appendChild(pop);
1452
+ const underModal = [...document.querySelectorAll("dialog")].some(
1453
+ (d) => d !== pop && d.open && d.matches(":modal"),
1454
+ );
1455
+ try {
1456
+ if (underModal) pop.showModal();
1457
+ else if (typeof pop.showPopover === "function") {
1458
+ pop.setAttribute("popover", "manual");
1459
+ pop.showPopover();
1460
+ } else pop.show();
1461
+ } catch {
1462
+ pop.setAttribute("open", "");
1463
+ }
1464
+ // A cancel (Escape on a modal) closes it as the chip would.
1465
+ pop.addEventListener("cancel", (e) => {
1466
+ e.preventDefault();
1467
+ closeOverlay(true);
1468
+ });
1379
1469
  const r = chip.getBoundingClientRect();
1380
1470
  pop.style.top = `${r.bottom + 6}px`;
1381
1471
  pop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - pop.offsetWidth - 8))}px`;
@@ -1441,6 +1531,15 @@ export function initTopbar(opts = {}) {
1441
1531
  $("den-lock")?.addEventListener("click", () =>
1442
1532
  overlay?.kind === "lock" ? closeOverlay(true) : openLock(),
1443
1533
  );
1534
+ // LK-2: back on this tab, the lock may have been opened elsewhere.
1535
+ document.addEventListener("visibilitychange", () => {
1536
+ if (document.visibilityState !== "visible" || lock.unlocked) return;
1537
+ if (sh()?.apiOff?.() === true) return;
1538
+ void getJson("/api/sudo-password/status", null).then((st) => {
1539
+ if (st && (st.set || st.passwordless))
1540
+ setUnlocked(true, !!st.passwordless && !st.set);
1541
+ });
1542
+ });
1444
1543
  document.addEventListener("keydown", (e) => {
1445
1544
  if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "k") {
1446
1545
  e.preventDefault();