@monster-orange/den 0.4.4 → 0.4.5

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 (43) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +10 -9
  3. package/docs/distributions.md +223 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/auth/sudo-vault.ts +115 -11
  7. package/src/shared/cli-command.ts +186 -0
  8. package/src/shared/confirm-code.ts +34 -0
  9. package/src/shared/den-config.ts +7 -0
  10. package/src/shared/dev-metrics/emit.ts +31 -1
  11. package/src/shared/edge/config.ts +39 -0
  12. package/src/shared/edge/render.ts +92 -18
  13. package/src/shared/edge/runtime.ts +57 -1
  14. package/src/shared/edge/types.ts +11 -1
  15. package/src/shared/github-app.ts +94 -0
  16. package/src/shared/github-device.ts +286 -0
  17. package/src/shared/secrets/onepassword.ts +27 -4
  18. package/src/shared/web-owner.ts +91 -0
  19. package/src/shared/web-session.ts +135 -1
  20. package/src/ui/cli/flag-parser.ts +1 -1
  21. package/src/ui/cli/web-command.ts +512 -33
  22. package/src/ui/server/device-flows.ts +274 -0
  23. package/src/ui/server/edge-exposure.ts +16 -4
  24. package/src/ui/server/github-routes.ts +321 -0
  25. package/src/ui/server/http-errors.ts +10 -0
  26. package/src/ui/server/index.ts +463 -27
  27. package/src/ui/server/login-waits.ts +203 -0
  28. package/src/ui/server/pair-requests.ts +237 -0
  29. package/src/ui/server/pair-routes.ts +196 -0
  30. package/src/ui/server/public/error-page.js +452 -40
  31. package/src/ui/server/public/error.html +59 -0
  32. package/src/ui/server/public/i18n/es.js +74 -8
  33. package/src/ui/server/public/i18n/ja.js +74 -8
  34. package/src/ui/server/public/local-dev.html +16 -4
  35. package/src/ui/server/public/qr.js +317 -0
  36. package/src/ui/server/public/settings-page.js +10 -3
  37. package/src/ui/server/public/settings-registry.js +392 -1
  38. package/src/ui/server/public/shell.js +123 -10
  39. package/src/ui/server/public/topbar.js +7 -3
  40. package/src/ui/server/public/workspace-picker.js +1 -1
  41. package/src/ui/server/request-guard.ts +102 -35
  42. package/src/ui/server/route-classes.ts +102 -2
  43. package/src/ui/server/ui-assets.ts +2 -0
@@ -101,21 +101,30 @@ function readData() {
101
101
  }
102
102
  }
103
103
 
104
- async function copy(text, btn, t) {
104
+ async function copyText(text) {
105
105
  try {
106
106
  await navigator.clipboard.writeText(text);
107
+ return;
107
108
  } catch {
108
- const ta = document.createElement("textarea");
109
- ta.value = text;
110
- document.body.appendChild(ta);
111
- ta.select();
112
- try {
113
- document.execCommand("copy");
114
- } catch {
115
- /* nothing more to try */
116
- }
117
- ta.remove();
109
+ /* no Clipboard API here (plain http beyond localhost): the fallback */
110
+ }
111
+ const ta = document.createElement("textarea");
112
+ ta.value = text;
113
+ ta.setAttribute("readonly", "");
114
+ ta.style.position = "fixed";
115
+ ta.style.opacity = "0";
116
+ document.body.appendChild(ta);
117
+ ta.select();
118
+ try {
119
+ document.execCommand("copy");
120
+ } catch {
121
+ /* nothing more to try */
118
122
  }
123
+ ta.remove();
124
+ }
125
+
126
+ async function copy(text, btn, t) {
127
+ await copyText(text);
119
128
  const label = btn.textContent;
120
129
  btn.textContent = t("err.copied");
121
130
  setTimeout(() => (btn.textContent = label), 1500);
@@ -126,50 +135,453 @@ function withCmd(text, cmd) {
126
135
  return esc(text).replace("\u0000", `<code class="mono">${esc(cmd)}</code>`);
127
136
  }
128
137
 
138
+ /** `t(key, vars)` escaped, with `{host}` (and `{target}`) in bold. */
139
+ function withHost(t, key, vars) {
140
+ const marks = {};
141
+ const v = { ...vars };
142
+ for (const k of ["host", "target"])
143
+ if (k in v) {
144
+ marks[k] = v[k];
145
+ v[k] = `\u0001${k}\u0001`;
146
+ }
147
+ let out = esc(t(key, v));
148
+ for (const [k, val] of Object.entries(marks))
149
+ out = out.split(`\u0001${k}\u0001`).join(`<b>${esc(val)}</b>`);
150
+ return out;
151
+ }
152
+
153
+ const COPY_ICON =
154
+ '<svg aria-hidden="true" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 3.5v-.5a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5"/></svg>';
155
+
156
+ /**
157
+ * One command on the page: the line itself, and an icon button right next
158
+ * to it that copies it ("Copied" for a moment after). `extra` is more
159
+ * actions beside it (Open Terminal). Every command the page prints comes
160
+ * from the server, built by one helper (cli-command.ts): never a bare
161
+ * `<cli>` the server's user can't run.
162
+ */
163
+ function cmdLine(id, cmd, t, extra = "") {
164
+ return (
165
+ `<div class="den-cmd" data-cmd="${esc(id)}">` +
166
+ `<pre class="code" id="${esc(id)}">${esc(cmd)}</pre>` +
167
+ `<button type="button" class="btn den-copy" data-copy-for="${esc(id)}" aria-label="${esc(t("err.signin.copy"))}" title="${esc(t("err.signin.copy"))}">` +
168
+ `${COPY_ICON}<span class="den-copied" aria-live="polite"></span></button>` +
169
+ extra +
170
+ `</div>`
171
+ );
172
+ }
173
+
174
+ /** Wire every copy button under `root` (each once). */
175
+ function wireCopy(root, t) {
176
+ root.querySelectorAll("button[data-copy-for]").forEach((b) => {
177
+ if (b.dataset.wired) return;
178
+ b.dataset.wired = "1";
179
+ let timer = null;
180
+ b.addEventListener("click", async () => {
181
+ const pre = root.querySelector(`#${CSS.escape(b.dataset.copyFor)}`);
182
+ await copyText(pre?.textContent ?? "");
183
+ const note = b.querySelector(".den-copied");
184
+ if (!note) return;
185
+ note.textContent = t("err.copied");
186
+ b.classList.add("done");
187
+ clearTimeout(timer);
188
+ timer = setTimeout(() => {
189
+ note.textContent = "";
190
+ b.classList.remove("done");
191
+ }, 1500);
192
+ });
193
+ });
194
+ }
195
+
196
+ /** `ms` left as m:ss ("9:59"); 0:00 at or past the end. */
197
+ export function countdownText(ms) {
198
+ const s = Math.max(0, Math.ceil(Number(ms) / 1000) || 0);
199
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
200
+ }
201
+
129
202
  /**
130
- * The signed-out page (SEC-1b, SEC-1 spec §2): "Sign in to use the
131
- * terminal" (a shell-grade route on loopback) or "Sign in to use <name>"
132
- * (beyond loopback), the command that prints a sign-in link with a copy
133
- * button, and on 127.0.0.1 or [::1] a link to the same page on localhost,
134
- * where the cookie is. It never asks for a secret or a token.
203
+ * Where a signed-in page goes: the page that was asked for. On /signin,
204
+ * its same-origin `next` (else `/`); anywhere else, the same URL.
205
+ */
206
+ export function landingAfterSignIn(loc) {
207
+ if (loc.pathname !== "/signin") return loc.pathname + loc.search + loc.hash;
208
+ const n = new URLSearchParams(loc.search).get("next") || "/";
209
+ return n.startsWith("/") && !n.startsWith("//") && !n.includes("\\")
210
+ ? n
211
+ : "/";
212
+ }
213
+
214
+ /**
215
+ * Sign in with GitHub (SEC-2a): the main button, shown only when a client
216
+ * id is configured. The server runs GitHub's device flow; the page shows
217
+ * the code and github.com/login/device, polls the server, and goes on once
218
+ * the owner approved it (the server sets the cookie). Another account is
219
+ * refused by name. With no owner linked, it says what to run on the server.
220
+ */
221
+ function githubBlock(s, t) {
222
+ const g = s.github;
223
+ if (!g || s.localhost || g.state === "no_client_id") return "";
224
+ const host = s.machine || "";
225
+ if (g.state === "no_owner")
226
+ return (
227
+ `<div id="den-gh"><p>${withHost(t, "err.signin.githubNoOwner", { name: s.name, host })}</p>` +
228
+ cmdLine("den-gh-owner-cmd", g.owner, t) +
229
+ `</div>`
230
+ );
231
+ return (
232
+ `<div id="den-gh"><div class="acts" style="margin-top:0">` +
233
+ `<button type="button" class="btn primary" id="den-gh-start">${esc(t("err.signin.github"))}</button>` +
234
+ `</div><p class="den-gh-note" style="margin-top:8px">${esc(t("err.signin.githubNote", { name: s.name }))}</p>` +
235
+ `<div id="den-gh-flow" aria-live="polite"></div></div>`
236
+ );
237
+ }
238
+
239
+ /** The device flow on the page: start, show the code, poll, go on. */
240
+ function wireGithub(root, t, s) {
241
+ const start = root.querySelector("#den-gh-start");
242
+ const box = root.querySelector("#den-gh-flow");
243
+ if (!start || !box) return;
244
+ let timer = null;
245
+ const show = (html) => (box.innerHTML = html);
246
+ const fail = (text) => {
247
+ clearTimeout(timer);
248
+ start.hidden = false;
249
+ start.textContent = t("err.signin.githubAgain");
250
+ show(
251
+ `<p class="den-gh-err" role="alert" style="margin-top:12px">${esc(text)}</p>`,
252
+ );
253
+ };
254
+ const poll = (id) => {
255
+ fetch(`/api/github/signin/${encodeURIComponent(id)}`, {
256
+ cache: "no-store",
257
+ credentials: "same-origin",
258
+ })
259
+ .then((r) => r.json().catch(() => ({})))
260
+ .then((b) => {
261
+ if (b.state === "pending") {
262
+ timer = setTimeout(() => poll(id), 1500);
263
+ return;
264
+ }
265
+ if (b.state === "ok") {
266
+ show(
267
+ `<p class="lead" style="margin-top:12px">${esc(t("err.signin.githubDone"))}</p>`,
268
+ );
269
+ location.replace(landingAfterSignIn(location));
270
+ return;
271
+ }
272
+ fail(
273
+ b.state === "refused"
274
+ ? t("err.signin.githubRefused", {
275
+ login: b.login ?? "",
276
+ name: s.name,
277
+ })
278
+ : b.state === "expired"
279
+ ? t("err.signin.githubExpired")
280
+ : b.state === "denied"
281
+ ? t("err.signin.githubDenied")
282
+ : t("err.signin.githubFailed"),
283
+ );
284
+ })
285
+ .catch(() => fail(t("err.signin.githubFailed")));
286
+ };
287
+ start.addEventListener("click", () => {
288
+ clearTimeout(timer);
289
+ start.disabled = true;
290
+ fetch("/api/github/signin", {
291
+ method: "POST",
292
+ cache: "no-store",
293
+ credentials: "same-origin",
294
+ headers: { "Content-Type": "application/json" },
295
+ body: "{}",
296
+ })
297
+ .then((r) =>
298
+ r
299
+ .json()
300
+ .catch(() => ({}))
301
+ .then((b) => [r.status, b]),
302
+ )
303
+ .then(([status, b]) => {
304
+ start.disabled = false;
305
+ if (!b.ok || !b.userCode) {
306
+ fail(
307
+ status === 429
308
+ ? t("err.signin.githubBusy")
309
+ : b.error || t("err.signin.githubFailed"),
310
+ );
311
+ return;
312
+ }
313
+ start.hidden = true;
314
+ const url = b.verificationUri || "https://github.com/login/device";
315
+ const shown = url.replace(/^https:\/\//, "");
316
+ show(
317
+ `<p class="lead" style="margin-top:12px">${t(
318
+ "err.signin.githubEnter",
319
+ {
320
+ url: `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${esc(shown)}</a>`,
321
+ },
322
+ )}</p>` +
323
+ `<pre class="code" id="den-gh-code" style="font-size:22px;letter-spacing:0.12em;text-align:center">${esc(b.userCode)}</pre>` +
324
+ `<div class="acts" style="margin-top:0">` +
325
+ `<a class="btn primary" id="den-gh-open" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${esc(t("err.signin.githubOpen"))}</a>` +
326
+ `<button type="button" class="btn" id="den-gh-copy">${esc(t("err.signin.githubCopy"))}</button></div>` +
327
+ `<p class="den-gh-wait" style="margin-top:12px">${esc(t("err.signin.githubWaiting"))}</p>`,
328
+ );
329
+ const cp = box.querySelector("#den-gh-copy");
330
+ cp?.addEventListener("click", () => copy(b.userCode, cp, t));
331
+ timer = setTimeout(() => poll(b.id), 1500);
332
+ })
333
+ .catch(() => {
334
+ start.disabled = false;
335
+ fail(t("err.signin.githubFailed"));
336
+ });
337
+ });
338
+ }
339
+
340
+ /**
341
+ * Approve this computer with a code (SEC-2a, the main fallback). The page
342
+ * asks the server for a request: a short code to approve it with, on the
343
+ * server (`<cli> web approve <code>`) or in a signed-in Settings › Access ›
344
+ * Waiting devices, and a poll id only this page holds. It polls; once
345
+ * approved, the poll sets the cookie and the page goes on by itself. A
346
+ * live countdown runs out at 10 minutes: "This code expired", and a new
347
+ * one is a click away. Denied, likewise.
348
+ */
349
+ function wirePair(root, t, s) {
350
+ const box = root.querySelector("#den-pair");
351
+ if (!box) return;
352
+ const host = s.machine || "";
353
+ const target = s.ssh?.target || "";
354
+ let poll = null;
355
+ let tick = null;
356
+ let gen = 0;
357
+ const stop = () => {
358
+ clearTimeout(poll);
359
+ clearTimeout(tick);
360
+ gen++;
361
+ };
362
+ const ended = (key) => {
363
+ stop();
364
+ box.innerHTML =
365
+ `<p class="den-pair-end" role="alert" id="den-pair-ended">${withHost(t, key, { host })}</p>` +
366
+ `<div class="acts" style="margin-top:0"><button type="button" class="btn primary" id="den-pair-new">${esc(t("err.signin.newCode"))}</button></div>`;
367
+ box.querySelector("#den-pair-new").addEventListener("click", begin);
368
+ };
369
+ const failed = () => {
370
+ stop();
371
+ box.innerHTML =
372
+ `<p class="den-pair-end" role="alert">${esc(t("err.signin.codeFailed", { name: s.name }))}</p>` +
373
+ `<div class="acts" style="margin-top:0"><button type="button" class="btn" id="den-pair-new">${esc(t("err.retry"))}</button></div>`;
374
+ box.querySelector("#den-pair-new").addEventListener("click", begin);
375
+ };
376
+ const show = (b) => {
377
+ const term =
378
+ s.ssh?.name && target
379
+ ? `<a class="btn" id="den-pair-term" href="ssh://${esc(target)}">${esc(t("err.signin.openTerminal"))}</a>`
380
+ : "";
381
+ box.innerHTML =
382
+ `<ol class="den-steps">` +
383
+ `<li><p>${withHost(t, "err.signin.step1", { host })}</p>` +
384
+ cmdLine("den-pair-cmd", b.approve, t) +
385
+ `<p>${withHost(t, "err.signin.step1Or", { name: s.name, host })}</p></li>` +
386
+ `<li><p>${esc(t("err.signin.step2"))}</p>` +
387
+ `<p class="den-pair-wait" id="den-pair-status">${esc(t("err.signin.waiting"))} <span class="mono" id="den-pair-left"></span></p></li>` +
388
+ `</ol>` +
389
+ `<p>${withHost(t, "err.signin.noTerminal", { host })}</p>` +
390
+ cmdLine("den-pair-ssh", b.ssh, t, term) +
391
+ (term
392
+ ? `<p class="den-pair-note">${withHost(t, "err.signin.sshNote", { target })}</p>`
393
+ : "");
394
+ wireCopy(box, t);
395
+ };
396
+ function begin() {
397
+ stop();
398
+ const mine = gen;
399
+ fetch("/api/signin/code", {
400
+ method: "POST",
401
+ cache: "no-store",
402
+ credentials: "same-origin",
403
+ headers: { "Content-Type": "application/json" },
404
+ body: "{}",
405
+ })
406
+ .then((r) => r.json().catch(() => ({})))
407
+ .then((b) => {
408
+ if (mine !== gen) return;
409
+ if (!b.ok || !b.id || !b.approve) return failed();
410
+ // The page's own clock, from what is left: no server clock skew.
411
+ const deadline = Date.now() + Number(b.expiresIn ?? 600_000);
412
+ show(b);
413
+ const left = box.querySelector("#den-pair-left");
414
+ const count = () => {
415
+ if (mine !== gen) return;
416
+ const ms = deadline - Date.now();
417
+ if (ms <= 0) return ended("err.signin.codeExpired");
418
+ left.textContent = countdownText(ms);
419
+ tick = setTimeout(count, Math.min(1000, ms % 1000 || 1000));
420
+ };
421
+ count();
422
+ const ask = () =>
423
+ fetch(`/api/signin/code/${encodeURIComponent(b.id)}`, {
424
+ cache: "no-store",
425
+ credentials: "same-origin",
426
+ })
427
+ .then((r) => r.json().catch(() => ({})))
428
+ .then((p) => {
429
+ if (mine !== gen) return;
430
+ if (p.state === "pending") {
431
+ poll = setTimeout(ask, 1500);
432
+ return;
433
+ }
434
+ if (p.state === "ok") {
435
+ stop();
436
+ box.innerHTML = `<p class="lead" id="den-pair-in">${esc(t("err.signin.youreIn"))}</p>`;
437
+ location.replace(landingAfterSignIn(location));
438
+ return;
439
+ }
440
+ ended(
441
+ p.state === "denied"
442
+ ? "err.signin.codeDenied"
443
+ : "err.signin.codeExpired",
444
+ );
445
+ })
446
+ .catch(() => {
447
+ if (mine === gen) poll = setTimeout(ask, 3000);
448
+ });
449
+ poll = setTimeout(ask, 1500);
450
+ })
451
+ .catch(() => {
452
+ if (mine === gen) failed();
453
+ });
454
+ }
455
+ begin();
456
+ }
457
+
458
+ /**
459
+ * The signed-out page (SEC-1b; SEC-2 spec "exact copy"): "Sign in to
460
+ * <product>", which machine it runs on, Sign in with GitHub (when a client
461
+ * id is configured), then "Or approve this computer" with a code, the ssh
462
+ * one-liner from this computer, why, and (collapsed) the one-time link.
463
+ * Every command names the machine it runs on and has a copy button. It
464
+ * never asks for a secret or a token, and never runs anything here.
135
465
  *
136
- * A link from 127.0.0.1 lands here once without the cookie (a cross-site
137
- * navigation doesn't carry a SameSite=Strict one); /api/session, asked
138
- * same-origin, does carry it, so a signed-in browser reloads the page
466
+ * On 127.0.0.1 or [::1] it links to the same page on localhost, where the
467
+ * cookie is. A link from 127.0.0.1 lands here once without the cookie (a
468
+ * cross-site navigation doesn't carry a SameSite=Strict one); /api/session,
469
+ * asked same-origin, does carry it, so a signed-in browser reloads the page
139
470
  * from here (same-origin) and is let through.
140
471
  */
141
472
  function mountSignedOut(root, sh, d) {
142
473
  const t = sh.t;
143
474
  const s = d.signin;
475
+ const name = s.name || "Den";
476
+ const host = s.machine || "";
144
477
  const title = s.expired
145
478
  ? t("err.signin.expiredTitle")
146
479
  : s.scope === "terminal"
147
480
  ? t("err.signin.terminal")
148
- : t("err.signin.title", { name: s.name || "Den" });
481
+ : t("err.signin.title", { name });
149
482
  document.title = document.title.replace(/Error$/, title);
150
- const lead = s.expired
151
- ? withCmd(t("err.signin.expired", { cmd: "\u0000" }), s.cmd)
152
- : withCmd(t("err.signin.lead", { cmd: "\u0000" }), s.cmd);
153
- root.innerHTML =
154
- `<div class="ph"><div><h1>${esc(title)}</h1>` +
155
- `<div class="sub">${esc(t("err.signin.sub"))}</div></div></div>` +
156
- `<div class="empty" id="den-signin"><p class="lead">${lead}</p>` +
157
- `<pre class="code" id="den-signin-cmd">${esc(s.cmd)}</pre>` +
158
- (s.localhost
159
- ? `<p>${esc(t("err.signin.local", { host: location.host }))}</p>`
160
- : "") +
161
- `<p class="den-signin-why">${esc(t("err.signin.why"))}</p>` +
483
+ const facts =
162
484
  `<dl class="facts">` +
163
485
  `<dt>${esc(t("err.500.id"))}</dt><dd class="mono" id="den-err-id">${esc(d.requestId)}</dd>` +
164
- `</dl><div class="acts">` +
165
- (s.localhost
166
- ? `<a class="btn primary" id="den-signin-local" href="${esc(s.localhost)}">${esc(t("err.421.open"))}</a>`
486
+ `</dl>`;
487
+ const head =
488
+ `<div class="ph"><div><h1>${esc(title)}</h1>` +
489
+ `<div class="sub">${esc(t("err.signin.sub"))}</div></div></div>`;
490
+ if (s.confirm) {
491
+ // SEC-2a: a link `web login` waits on: the code it printed, first.
492
+ root.innerHTML =
493
+ `<div class="ph"><div><h1>${esc(t("err.signin.title", { name }))}</h1>` +
494
+ `<div class="sub">${esc(t("err.signin.sub"))}</div></div></div>` +
495
+ `<div class="empty" id="den-signin">` +
496
+ `<form id="den-confirm" autocomplete="off">` +
497
+ `<p class="lead"><label for="den-confirm-code">${withHost(t, "err.signin.confirmLead", { host })}</label></p>` +
498
+ `<div class="acts" style="margin-top:0">` +
499
+ `<input id="den-confirm-code" class="mono" name="code" required maxlength="12" spellcheck="false" autocapitalize="characters" placeholder="ABCD-EFGH" ` +
500
+ `style="font:16px/1.45 var(--mono);letter-spacing:0.08em;padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-button);background:var(--sunken);color:var(--text);width:12ch">` +
501
+ `<button type="submit" class="btn primary" id="den-confirm-go">${esc(t("err.signin.confirmGo"))}</button></div>` +
502
+ `<p class="den-confirm-err" id="den-confirm-err" role="alert" style="margin-top:12px"></p>` +
503
+ `</form>` +
504
+ `<p class="den-signin-why">${withHost(t, "err.signin.confirmWhy", { host })}</p>` +
505
+ facts +
506
+ `</div>`;
507
+ const form = root.querySelector("#den-confirm");
508
+ const input = root.querySelector("#den-confirm-code");
509
+ const err = root.querySelector("#den-confirm-err");
510
+ const go = root.querySelector("#den-confirm-go");
511
+ input?.focus();
512
+ form?.addEventListener("submit", (e) => {
513
+ e.preventDefault();
514
+ go.disabled = true;
515
+ err.textContent = "";
516
+ fetch(location.pathname + location.search, {
517
+ method: "POST",
518
+ cache: "no-store",
519
+ credentials: "same-origin",
520
+ headers: { "Content-Type": "application/json" },
521
+ body: JSON.stringify({ code: input.value }),
522
+ })
523
+ .then((r) => r.json().catch(() => ({})))
524
+ .then((b) => {
525
+ go.disabled = false;
526
+ if (b.ok) {
527
+ err.textContent = "";
528
+ form.innerHTML = `<p class="lead" id="den-confirm-in">${esc(t("err.signin.youreIn"))}</p>`;
529
+ location.replace(typeof b.next === "string" ? b.next : "/");
530
+ return;
531
+ }
532
+ err.textContent =
533
+ b.code === "wrong_code"
534
+ ? t("err.signin.confirmWrong")
535
+ : b.code === "rate_limited"
536
+ ? t("err.signin.confirmBusy")
537
+ : t("err.signin.confirmGone", { host });
538
+ if (b.code !== "wrong_code" && b.code !== "rate_limited")
539
+ go.disabled = true;
540
+ input.select();
541
+ })
542
+ .catch(() => {
543
+ go.disabled = false;
544
+ err.textContent = t("err.signin.confirmBusy");
545
+ });
546
+ });
547
+ return;
548
+ }
549
+ if (s.localhost) {
550
+ // 127.0.0.1 or [::1]: the cookie is on localhost. Nothing to approve.
551
+ root.innerHTML =
552
+ head +
553
+ `<div class="empty" id="den-signin">` +
554
+ `<p class="lead">${esc(t("err.signin.local", { host: location.host }))}</p>` +
555
+ facts +
556
+ `<div class="acts"><a class="btn primary" id="den-signin-local" href="${esc(s.localhost)}">${esc(t("err.421.open"))}</a></div></div>`;
557
+ return;
558
+ }
559
+ const gh = githubBlock(s, t);
560
+ root.innerHTML =
561
+ head +
562
+ `<div class="empty" id="den-signin">` +
563
+ (s.expired
564
+ ? `<p class="lead" role="alert">${esc(t("err.signin.expiredLead"))}</p>`
167
565
  : "") +
168
- `<button type="button" class="btn${s.localhost ? "" : " primary"}" id="den-signin-copy">${esc(t("err.signin.copy"))}</button>` +
169
- `</div></div>`;
170
- const btn = root.querySelector("#den-signin-copy");
171
- btn?.addEventListener("click", () => copy(s.cmd, btn, t));
172
- if (s.expired || s.localhost) return;
566
+ `<p class="lead" id="den-signin-where">${withHost(t, "err.signin.where", { name, host })}</p>` +
567
+ gh +
568
+ `<h2 class="den-h" id="den-pair-title">${esc(t(gh ? "err.signin.approveOr" : "err.signin.approve"))}</h2>` +
569
+ `<div id="den-pair" aria-live="polite"></div>` +
570
+ `<p class="den-signin-why"><b>${esc(t("err.signin.whyTitle"))}</b> ${withHost(t, "err.signin.whyText", { name, host })}</p>` +
571
+ `<details class="den-other" id="den-signin-other"><summary>${withCmd(t("err.signin.other", { cmd: "\u0000" }), "web login --origin")}</summary>` +
572
+ `<p>${withHost(t, "err.signin.otherStep", { host })}</p>` +
573
+ cmdLine("den-signin-cmd", s.cmd, t) +
574
+ (s.ssh?.login
575
+ ? `<p>${withHost(t, "err.signin.noTerminal", { host })}</p>` +
576
+ cmdLine("den-signin-ssh", s.ssh.login, t)
577
+ : "") +
578
+ `</details>` +
579
+ facts +
580
+ `</div>`;
581
+ wireCopy(root, t);
582
+ wireGithub(root, t, s);
583
+ wirePair(root, t, s);
584
+ if (s.expired) return;
173
585
  let tried = false;
174
586
  try {
175
587
  tried = sessionStorage.getItem("den-signin-reload") === location.href;
@@ -217,6 +217,65 @@
217
217
  .empty.wide {
218
218
  max-width: 880px;
219
219
  }
220
+ /* The signed-out page (SEC-2a): a command and its copy button on one
221
+ line, the numbered steps, and "Other ways" collapsed. The
222
+ prototype's tokens and sizes only (pre.code, .btn, .lead). */
223
+ .den-cmd {
224
+ display: flex;
225
+ gap: 8px;
226
+ align-items: flex-start;
227
+ margin: 0 0 12px;
228
+ }
229
+ .den-cmd pre.code {
230
+ flex: 1 1 auto;
231
+ min-width: 0;
232
+ margin: 0;
233
+ /* The whole command in view: it wraps rather than scrolls. */
234
+ white-space: pre-wrap;
235
+ overflow-wrap: anywhere;
236
+ }
237
+ .btn.den-copy {
238
+ padding: 6px 8px;
239
+ align-self: stretch;
240
+ color: var(--muted);
241
+ }
242
+ .btn.den-copy:hover,
243
+ .btn.den-copy.done {
244
+ color: var(--text);
245
+ }
246
+ .den-copied:empty {
247
+ display: none;
248
+ }
249
+ .den-copied {
250
+ font: 500 12px/1.45 var(--sans);
251
+ }
252
+ h2.den-h {
253
+ font: 600 15px/1.3 var(--sans);
254
+ color: var(--text);
255
+ margin: 20px 0 10px;
256
+ }
257
+ ol.den-steps {
258
+ margin: 0 0 12px;
259
+ padding-left: 20px;
260
+ }
261
+ ol.den-steps li {
262
+ margin: 0 0 4px;
263
+ }
264
+ ol.den-steps li::marker {
265
+ color: var(--text);
266
+ font-weight: 600;
267
+ }
268
+ details.den-other {
269
+ margin: 0 0 16px;
270
+ }
271
+ details.den-other summary {
272
+ cursor: pointer;
273
+ color: var(--muted);
274
+ margin: 0 0 12px;
275
+ }
276
+ details.den-other[open] summary {
277
+ color: var(--text);
278
+ }
220
279
  ul.pages {
221
280
  list-style: none;
222
281
  margin: 0;