toolroll 0.9.86 → 0.9.87

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 (171) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/agents-board.d.ts +12 -0
  3. package/dist/agents-board.js +26 -0
  4. package/dist/approval-rules-ui.d.ts +4 -1
  5. package/dist/approval-rules-ui.js +31 -15
  6. package/dist/assignment-ui.js +1 -1
  7. package/dist/backup-ui.d.ts +5 -4
  8. package/dist/backup-ui.js +65 -32
  9. package/dist/browser/terminal.css +1 -1
  10. package/dist/browser/terminal.js +57 -54
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +35 -70
  13. package/dist/browser-crew.js +1 -1
  14. package/dist/browser-workspace.d.ts +329 -183
  15. package/dist/browser-workspace.js +10 -9
  16. package/dist/chat-controls.d.ts +1 -1
  17. package/dist/chat-controls.js +1 -1
  18. package/dist/chat-settings-page.d.ts +29 -9
  19. package/dist/chat-settings-page.js +59 -28
  20. package/dist/check-levels-ui.d.ts +2 -1
  21. package/dist/check-levels-ui.js +67 -58
  22. package/dist/contracts/cli.d.ts +43 -0
  23. package/dist/contracts/cli.js +4 -0
  24. package/dist/contracts/console-api.d.ts +1 -1
  25. package/dist/contracts/console-api.js +1 -1
  26. package/dist/credentials-ui.d.ts +5 -3
  27. package/dist/credentials-ui.js +58 -31
  28. package/dist/design/kit.css +685 -0
  29. package/dist/design/kit.d.ts +208 -0
  30. package/dist/design/kit.js +177 -0
  31. package/dist/design/tokens.css +19 -1
  32. package/dist/discord-settings.js +8 -4
  33. package/dist/export-ui.d.ts +6 -4
  34. package/dist/export-ui.js +18 -8
  35. package/dist/flow-changed.d.ts +14 -0
  36. package/dist/flow-changed.js +53 -0
  37. package/dist/flow-gallery-ui.d.ts +41 -4
  38. package/dist/flow-gallery-ui.js +95 -63
  39. package/dist/flow-starters-ui.d.ts +3 -9
  40. package/dist/flow-starters-ui.js +12 -23
  41. package/dist/flow-unattended-words.d.ts +20 -0
  42. package/dist/flow-unattended-words.js +24 -0
  43. package/dist/flow-unattended.d.ts +9 -5
  44. package/dist/flow-unattended.js +45 -23
  45. package/dist/flows-cli.js +7 -3
  46. package/dist/flows-ui.d.ts +12 -6
  47. package/dist/flows-ui.js +141 -30
  48. package/dist/integrations-ui.d.ts +6 -4
  49. package/dist/integrations-ui.js +34 -40
  50. package/dist/integrations.d.ts +2 -2
  51. package/dist/integrations.js +4 -4
  52. package/dist/kits-ui.d.ts +8 -10
  53. package/dist/kits-ui.js +29 -33
  54. package/dist/kits.js +1 -1
  55. package/dist/knowledge-ui.js +76 -19
  56. package/dist/ledger-view.d.ts +2 -0
  57. package/dist/ledger-view.js +41 -38
  58. package/dist/models-ui.d.ts +8 -3
  59. package/dist/models-ui.js +82 -59
  60. package/dist/monitoring-ui.d.ts +4 -2
  61. package/dist/monitoring-ui.js +44 -13
  62. package/dist/needs-you.d.ts +1 -1
  63. package/dist/needs-you.js +8 -0
  64. package/dist/operate-remote-arguments.js +1 -0
  65. package/dist/operate-remote.js +1 -1
  66. package/dist/operate.d.ts +1 -1
  67. package/dist/operate.js +38 -4
  68. package/dist/passwordless.d.ts +1 -1
  69. package/dist/passwordless.js +1 -1
  70. package/dist/people-audit-ui.d.ts +2 -1
  71. package/dist/people-audit-ui.js +25 -23
  72. package/dist/policy-ui.d.ts +3 -2
  73. package/dist/policy-ui.js +27 -21
  74. package/dist/policy.d.ts +3 -0
  75. package/dist/policy.js +14 -1
  76. package/dist/project-delete-ui.d.ts +4 -2
  77. package/dist/project-delete-ui.js +135 -126
  78. package/dist/project-knowledge.d.ts +10 -10
  79. package/dist/publish.d.ts +11 -0
  80. package/dist/publish.js +16 -3
  81. package/dist/pull-request-ui.d.ts +2 -1
  82. package/dist/pull-request-ui.js +39 -28
  83. package/dist/recipe-ui.d.ts +4 -2
  84. package/dist/recipe-ui.js +14 -4
  85. package/dist/release-adapters.d.ts +3 -0
  86. package/dist/release-adapters.js +22 -2
  87. package/dist/release-cli.js +81 -9
  88. package/dist/release.d.ts +21 -0
  89. package/dist/release.js +40 -4
  90. package/dist/releases.js +1 -1
  91. package/dist/request-budget-ui.d.ts +4 -2
  92. package/dist/request-budget-ui.js +30 -19
  93. package/dist/retention-ui.d.ts +4 -3
  94. package/dist/retention-ui.js +15 -12
  95. package/dist/scope.d.ts +3 -0
  96. package/dist/scope.js +6 -0
  97. package/dist/serve.d.ts +0 -1
  98. package/dist/serve.js +51 -7
  99. package/dist/server/agents-computers.d.ts +9 -0
  100. package/dist/server/agents-computers.js +67 -0
  101. package/dist/server/chat.js +16 -16
  102. package/dist/server/chrome.d.ts +1 -1
  103. package/dist/server/chrome.js +19 -9
  104. package/dist/server/console.css +2 -2
  105. package/dist/server/flows.js +64 -36
  106. package/dist/server/home.d.ts +35 -0
  107. package/dist/server/home.js +104 -0
  108. package/dist/server/http.d.ts +3 -0
  109. package/dist/server/http.js +5 -0
  110. package/dist/server/kit-standalone.d.ts +22 -0
  111. package/dist/server/kit-standalone.js +69 -0
  112. package/dist/server/kit.d.ts +405 -0
  113. package/dist/server/kit.js +361 -0
  114. package/dist/server/pages.d.ts +2 -1
  115. package/dist/server/pages.js +213 -174
  116. package/dist/server/people-tokens.js +26 -85
  117. package/dist/server/render-history.d.ts +51 -0
  118. package/dist/server/render-history.js +228 -0
  119. package/dist/server/render-new-task.d.ts +29 -0
  120. package/dist/server/render-new-task.js +96 -0
  121. package/dist/server/render-next.d.ts +63 -0
  122. package/dist/server/render-next.js +109 -0
  123. package/dist/server/render-pages.d.ts +22 -190
  124. package/dist/server/render-pages.js +123 -575
  125. package/dist/server/render-people.d.ts +24 -3
  126. package/dist/server/render-people.js +93 -72
  127. package/dist/server/render-projects.d.ts +103 -0
  128. package/dist/server/render-projects.js +247 -0
  129. package/dist/server/render-results.d.ts +5 -24
  130. package/dist/server/render-results.js +48 -185
  131. package/dist/server/render-settings.d.ts +5 -74
  132. package/dist/server/render-settings.js +102 -493
  133. package/dist/server/render-tasks.d.ts +35 -5
  134. package/dist/server/render-tasks.js +114 -85
  135. package/dist/server/route-table.js +20 -5
  136. package/dist/server/settings.js +276 -255
  137. package/dist/server/task-views.d.ts +8 -0
  138. package/dist/server/task-views.js +85 -2
  139. package/dist/server/tasks.d.ts +8 -0
  140. package/dist/server/tasks.js +95 -40
  141. package/dist/server/terminals.js +1 -1
  142. package/dist/server/welcome.d.ts +22 -0
  143. package/dist/server/welcome.js +69 -0
  144. package/dist/settings-groups-ui.d.ts +279 -0
  145. package/dist/settings-groups-ui.js +615 -0
  146. package/dist/settings-kit.d.ts +42 -6
  147. package/dist/settings-kit.js +69 -7
  148. package/dist/setup-guide.d.ts +2 -2
  149. package/dist/skills-ui.js +59 -7
  150. package/dist/slack-settings.js +12 -5
  151. package/dist/spend-ui.d.ts +45 -26
  152. package/dist/spend-ui.js +124 -56
  153. package/dist/sso-ui.d.ts +5 -2
  154. package/dist/sso-ui.js +63 -31
  155. package/dist/storage-ui.d.ts +3 -2
  156. package/dist/storage-ui.js +56 -50
  157. package/dist/store.d.ts +112 -3
  158. package/dist/store.js +292 -20
  159. package/dist/surface.js +5 -1
  160. package/dist/teams-settings.d.ts +2 -1
  161. package/dist/teams-settings.js +24 -41
  162. package/dist/telegram-settings.d.ts +0 -4
  163. package/dist/telegram-settings.js +52 -25
  164. package/dist/toolroll-update-ui.d.ts +8 -4
  165. package/dist/toolroll-update-ui.js +55 -45
  166. package/dist/tools-ui.d.ts +5 -3
  167. package/dist/tools-ui.js +107 -42
  168. package/dist/work-index.js +11 -7
  169. package/dist/workspace-ui.d.ts +1 -1
  170. package/dist/workspace-ui.js +7 -4
  171. package/package.json +1 -1
package/dist/spend-ui.js CHANGED
@@ -1,28 +1,35 @@
1
1
  /**
2
- * The Spend page (docs/plans/spend-dashboard.md, slice 1): what agent work cost this month, with API dollars, plan
3
- * usage and unknown cost kept apart and never added together, broken down by project, model, role and person, the
4
- * runs that cost most, and what isn't recorded here. An instance operator's page. Monthly limits show read-only here
5
- * and change on Settings → Spend, with a step-up.
2
+ * The Spend page (docs/plans/spend-dashboard.md, slice 1), in the kit's Settings frame (group "spend"): the monthly
3
+ * limits first (the one that needs you on top), then what agent work cost in a month, with API dollars, plan usage
4
+ * and unknown cost kept apart and never added together, broken down by project and model (and, on demand, role,
5
+ * person and the costliest runs), the plan windows, and what isn't recorded here. An instance operator's page.
6
+ * A limit changes in place: Edit limit opens its form under the row (`?edit=<target>`), saved with a step-up.
6
7
  */
7
- import { html, postForm, reauth } from "./html.js";
8
+ import { html, postForm } from "./html.js";
9
+ import { approvalAsk, termsOf } from "./design/kit.js";
10
+ import { acts, btn, chip, disclose, dock, empty, field, input, note, notice, segLinks, settingsShell } from "./server/kit.js";
8
11
  import { budgetLabel, usd } from "./spend.js";
9
12
  import { runHref, taskHref } from "./server/http.js";
10
13
  const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
11
- export const SPEND_CSS = `.spend{max-width:960px;min-width:0}.spend-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.spend-head h1{margin:0}` +
12
- `.spend-month{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;font-size:.875rem;margin:0}` +
13
- `.spend h2{font-size:.9375rem;margin:24px 0 8px}.spend table{width:100%;border-collapse:collapse;font-size:.8125rem}.spend th,.spend td{text-align:left;padding:6px 8px 6px 0;border-bottom:1px solid var(--border);overflow-wrap:anywhere}` +
14
- `.spend th{font-weight:500;color:var(--muted-foreground)}.spend td.n,.spend th.n{text-align:right;font-variant-numeric:tabular-nums}.spend td.n,.spend th.n{white-space:nowrap}.spend table.lines td:first-child{overflow-wrap:break-word;width:40%}` +
15
- `.spend-figures{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:16px 0 4px}.spend-figure{border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:0}` +
16
- `.spend-figure h2{margin:0;font-size:.8125rem;font-weight:500;color:var(--muted-foreground)}.spend-figure .value{margin:6px 0 0;font-size:1.5rem;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}` +
17
- `.spend-figure .meta{margin:4px 0 0}.spend-not-counted ul{margin:0;padding-left:20px;font-size:.8125rem}.spend-not-counted li{margin:2px 0}` +
18
- `.budget{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:10px 0;border-bottom:1px solid var(--border)}.budget .name{font-weight:500;font-size:.875rem}` +
19
- `.budget .figures{font-size:.8125rem;font-variant-numeric:tabular-nums;text-align:right}.budget .bar{grid-column:1/-1;height:6px;border-radius:3px;background:var(--muted);overflow:hidden}` +
20
- `.budget .bar span{display:block;height:100%;background:var(--foreground)}.budget.over .bar span{background:var(--danger)}.budget.over .figures{color:var(--danger)}.budget .meta{grid-column:1/-1}` +
21
- `.budget details{grid-column:1/-1}.spend form.budget-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px 12px;align-items:end;margin:8px 0}` +
22
- `.spend form.budget-form label{display:grid;gap:4px;font-size:.8125rem}.spend form.budget-form .choice{display:flex;gap:8px;align-items:center}.spend form.budget-form button{justify-self:start}` +
23
- `.spend-models dl{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 16px;margin:0 0 8px;font-size:.875rem}.spend-models dt{color:var(--muted-foreground)}.spend-models dd{margin:0;overflow-wrap:anywhere}` +
24
- `@media(max-width:640px){.spend-figures{grid-template-columns:minmax(0,1fr)}.spend table.lines thead{display:none}.spend table.lines tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px 12px;padding:8px 0;border-bottom:1px solid var(--border)}` +
25
- `.spend table.lines td{border:0;padding:0;white-space:normal}.spend table.lines td:first-child{grid-column:1/-1;font-weight:500;width:auto}.spend table.lines td.n{text-align:left;color:var(--muted-foreground)}.spend table.lines td.n::before{content:attr(data-label);display:block;font-size:.6875rem}}`;
14
+ /** The page's own layout: the limit rows and meters, the summary figures and the quiet bars. Colour from tokens only. */
15
+ export const SPEND_CSS = `.spend{display:grid;gap:24px;min-width:0}.spend-sec{display:grid;gap:0}.spend-sec .k-secthead{margin-bottom:0}.spend-sec .k-secthead small{font-weight:400;color:var(--so-muted);font-size:12.5px}` +
16
+ `.spend-lim{display:grid;gap:8px;padding:12px 0;border-bottom:1px solid var(--so-line)}.spend-lim-h{display:grid;grid-template-columns:minmax(0,1fr) auto 124px;gap:12px;align-items:center}` +
17
+ `.spend-lim-l{display:grid;gap:2px;min-width:0}.spend-lim-t{font-weight:500;display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap;overflow-wrap:anywhere}.spend-lim-x{font-size:12.5px;color:var(--so-muted)}` +
18
+ `.spend-fig{font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spend-lim-a{display:flex;justify-content:flex-end}.spend-lim-a .k-btn{min-width:108px}` +
19
+ `.spend-meter{display:block;height:6px;border-radius:3px;background:var(--so-soft);overflow:hidden}.spend-meter>i{display:block;height:100%;border-radius:3px;background:var(--so-neutral-ink)}.spend-meter.over>i{background:var(--so-danger)}` +
20
+ `.spend-edit{display:grid;gap:12px;padding:4px 0 4px}.spend-edit-row{display:flex;flex-wrap:wrap;gap:12px 20px;align-items:flex-end}.spend-edit-row .k-field{flex:0 1 220px}.spend-edit-row .k-checks{min-height:32px}` +
21
+ `.spend-edit>.k-field{max-width:300px}.spend-month{display:flex;align-items:center;gap:12px;flex-wrap:wrap}` +
22
+ `.spend-sum{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border-top:1px solid var(--so-line);border-bottom:1px solid var(--so-line)}.spend-sum>div{display:grid;gap:4px;align-content:start;padding:16px 16px 16px 0;min-width:0}` +
23
+ `.spend-sum>div+div{border-left:1px solid var(--so-line);padding-left:16px}.spend-sum-k{font-size:12px;color:var(--so-muted)}.spend-sum-v{font-size:20px;font-weight:600;letter-spacing:-.01em;font-variant-numeric:tabular-nums}.spend-sum-x{font-size:12px;color:var(--so-muted);line-height:1.45}` +
24
+ `.spend-bars{display:grid}.spend-bar{display:grid;grid-template-columns:minmax(0,200px) minmax(0,1fr) 84px;gap:12px;align-items:center;min-height:40px;padding:6px 0;border-bottom:1px solid var(--so-line)}` +
25
+ `.spend-bar.h{min-height:28px;font-size:11.5px;color:var(--so-muted)}.spend-bar.tot{font-weight:600;border-bottom:0}.spend-bar-n{min-width:0;overflow-wrap:anywhere}.spend-bar-n small{display:block;font-size:11.5px;color:var(--so-muted)}` +
26
+ `.spend-lane{position:relative;height:14px}.spend-lane>i{position:absolute;left:0;top:4px;height:6px;border-radius:3px;background:var(--so-neutral-ink);min-width:3px}.spend-bar-v{text-align:right;font-variant-numeric:tabular-nums}` +
27
+ `.spend-runs{display:grid}.spend-run{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:8px 0;border-bottom:1px solid var(--so-line)}.spend-run small{grid-column:1/-1;color:var(--so-muted);font-size:12px;overflow-wrap:anywhere}` +
28
+ `.spend-wins{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;padding-top:12px}.spend-win{display:grid;gap:6px;min-width:0}.spend-win-h{display:flex;justify-content:space-between;gap:8px}.spend-win-h b{font-weight:500}` +
29
+ `.spend-win.warning .spend-meter>i,.spend-win.danger .spend-meter>i{background:var(--so-danger)}.spend .k-disclose{padding-top:4px}.spend .k-disclose[open]>summary{margin-bottom:8px}` +
30
+ `@media(max-width:760px){.spend-lim-h{grid-template-columns:minmax(0,1fr) auto}.spend-lim-a{grid-column:1/-1}.spend-lim-a .k-btn{width:100%}.spend-sum{grid-template-columns:minmax(0,1fr)}` +
31
+ `.spend-sum>div+div{border-left:0;border-top:1px solid var(--so-line);padding-left:0}.spend-bar{grid-template-columns:minmax(0,120px) minmax(0,1fr) 68px;gap:8px;font-size:12.5px}` +
32
+ `.spend-edit-row .k-field{flex:1 1 100%}.spend .k-btn.k-btn-primary.k-docked{display:none}}`;
26
33
  const ROLE_WORDS = {
27
34
  builder: "Builds", repair: "Repairs", planner: "Plans", reviewer: "Reviews", scout: "Scouting",
28
35
  lead: "Lead chat", chat: "Chat", subagent: "Subagents", flow: "Flows", evals: "Evals",
@@ -38,6 +45,9 @@ export const NOT_COUNTED = [
38
45
  /** Dollars for the page: a positive amount under a cent reads "<$0.01", never a false $0. */
39
46
  export const money = (microusd) => microusd > 0 && microusd < 10_000 ? "<$0.01" : usd(microusd);
40
47
  const each = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
48
+ const monthWords = (name, style = "long") => new Intl.DateTimeFormat("en-US", { month: style, year: "numeric", timeZone: "UTC" }).format(new Date(`${name}-01T00:00:00.000Z`));
49
+ /** A limit Settings flags as Needs you: one that pauses work and is at or near its limit. */
50
+ export const limitNeedsYou = (budget) => budget.hardStop && budget.percent >= 90;
41
51
  /** A line's plan usage in words: the reported usage, or that none was reported. Never $0 for unreported usage. */
42
52
  const usageWords = (line) => line.usageCount === 0 ? "—" : line.usageMicrousd > 0 ? `${money(line.usageMicrousd)}${line.usageUnreported > 0 ? ` + ${line.usageUnreported} not reported` : ""}` : "not reported";
43
53
  /** A line's unknown cost in words: how many, and a saved charge as an estimate. Never $0. */
@@ -46,52 +56,110 @@ const unknownWords = (line) => {
46
56
  return line.unknown > 0 ? `${line.unknown}${counted === "" ? "" : ` · ${counted}`}` : counted === "" ? "—" : counted;
47
57
  };
48
58
  const apiWords = (line) => line.apiMicrousd > 0 ? money(line.apiMicrousd) : "—";
49
- function linesTable(title, lines, name, top = 12, detail = () => "") {
50
- if (lines.length === 0)
51
- return "";
52
- return html `<section><h2>${title}</h2><table class="lines"><thead><tr><th></th><th class="n">API dollars</th><th class="n">Plan usage</th><th class="n">Unknown</th></tr></thead><tbody>${lines.slice(0, top).map(line => html `<tr><td>${name(line.key)}${detail(line) === "" ? "" : html ` <span class="meta">${detail(line)}</span>`}</td><td class="n" data-label="API dollars" data-amount-kind="api">${apiWords(line)}</td><td class="n" data-label="Plan usage" data-amount-kind="subscription">${usageWords(line)}</td><td class="n" data-label="Unknown" data-amount-kind="unknown">${unknownWords(line)}</td></tr>`)}</tbody></table></section>`;
59
+ const sec = (id, title, state, body, attrs = "") => html `<section class="k-sec spend-sec" aria-labelledby="spend-${id}"${attrs}><div class="k-secthead"><h3 id="spend-${id}">${title}${state ? html ` <small>· ${state}</small>` : ""}</h3></div>${body}</section>`;
60
+ /** A breakdown as quiet bars of API dollars; plan usage and unknown cost stay on the line, never added in. */
61
+ function bars(head, lines, name, max, top = 12, detail = () => "") {
62
+ const rows = lines.slice(0, top).map(line => {
63
+ const sub = [detail(line), line.usageCount > 0 ? `plan usage ${usageWords(line)}` : "", line.unknown > 0 || line.estimatedMicrousd > 0 ? `unknown ${unknownWords(line)}` : ""].filter(Boolean).join(" · ");
64
+ const width = max > 0 ? Math.max(0, Math.min(100, (line.apiMicrousd / max) * 100)) : 0;
65
+ return html `<div class="spend-bar" role="row"><span class="spend-bar-n" role="cell">${name(line.key)}${sub === "" ? "" : html `<small data-amount-kind="${line.usageCount > 0 ? "subscription" : "unknown"}">${sub}</small>`}</span>\
66
+ <span class="spend-lane" role="cell" aria-hidden="true">${line.apiMicrousd > 0 ? html `<i style="width:${width.toFixed(1)}%"></i>` : ""}</span><span class="spend-bar-v" role="cell" data-amount-kind="api">${apiWords(line)}</span></div>`;
67
+ });
68
+ const total = lines.reduce((sum, line) => sum + line.apiMicrousd, 0);
69
+ return html `<div class="spend-bars" role="table" aria-label="${head}"><div class="spend-bar h" role="row"><span role="columnheader">${head}</span><span role="columnheader"></span><span class="spend-bar-v" role="columnheader">API dollars</span></div>${rows}\
70
+ <div class="spend-bar tot" role="row"><span role="cell">Total</span><span role="cell"></span><span class="spend-bar-v" role="cell">${total > 0 ? money(total) : "—"}</span></div></div>`;
53
71
  }
54
- /** A monthly limit's row; `editor` is Settings → Spend's change form, nothing on the Spend page. */
55
- function limitRow(budget, nameOfSubagent, editor) {
56
- const width = Math.min(100, Math.max(0, budget.percent));
57
- const over = budget.spentMicrousd >= budget.limitMicrousd;
58
- const label = budgetLabel(budget, budget.scope === "subagent" ? nameOfSubagent(budget.key) : undefined).replace(/'s$/, "");
59
- return html `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${label}</span><span class="figures">${money(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span><span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span><span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher limit." : budget.hardStop ? "Pauses new API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} without a price.` : ""}</span>${editor}</div>`;
72
+ const editHref = (target, month, current) => `/spend?${month === current ? "" : `month=${month}&`}edit=${encodeURIComponent(target)}`;
73
+ /** The open limit form, under its row: amount and pause, validated next to the field before any password; saving
74
+ * is a step-up (/spend/budget proves the password again, in the write). */
75
+ function limitForm(row, edit, view, targets) {
76
+ const id = `limit-${edit.target.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60) || "new"}`;
77
+ const facts = view.facts ?? { passwordless: false, remembered: false };
78
+ const amount = edit.usd ?? (row?.budget ? String(Math.round(row.budget.limitMicrousd / 1_000_000)) : "");
79
+ const stop = edit.stop ?? row?.budget?.hardStop ?? true;
80
+ const choose = row === null && targets !== undefined
81
+ ? field({ id: `${id}-for`, label: "For", control: html `<span class="k-selw wide"><select class="k-sel" id="${id}-for" name="target" required>${["People", "Subagents"].map(group => {
82
+ const members = targets.filter(one => one.group === group);
83
+ return members.length === 0 ? "" : html `<optgroup label="${group}">${members.map(one => html `<option value="${one.value}"${one.value === edit.target ? html ` selected` : ""}>${one.label}</option>`)}</optgroup>`;
84
+ })}</select></span>` })
85
+ : "";
86
+ const password = approvalAsk("step-up", facts) === "password"
87
+ ? field({ id: `${id}-pw`, label: "Your password", control: input({ id: `${id}-pw`, name: "password", type: "password", attrs: { autocomplete: "current-password", required: true } }) })
88
+ : "";
89
+ const back = view.month === view.current ? "/spend" : `/spend?month=${view.month}`;
90
+ const body = html `${choose}<div class="spend-edit-row">${field({ id: `${id}-usd`, label: "Monthly limit (US dollars)", error: edit.fieldError ?? null,
91
+ control: input({ id: `${id}-usd`, name: "usd", value: amount, error: Boolean(edit.fieldError), attrs: { inputmode: "numeric", autocomplete: "off", "data-spend-usd": "", autofocus: true } }) })}\
92
+ <div class="k-checks"><label><input type="checkbox" name="stop" value="1"${stop ? html ` checked` : ""}>Pause new API work at the limit</label></div></div>\
93
+ ${note(`Unchecked means alerts only. Plan usage never counts toward a limit. ${termsOf("step-up", facts)}`)}${password}\
94
+ ${acts(btn("Save limit", { variant: "primary", type: "submit", docked: true, attrs: { name: "action", value: "save" } }), row?.budget ? btn("Remove limit", { variant: "ghost", type: "submit", attrs: { name: "action", value: "remove", formnovalidate: true, "data-spend-remove": "" } }) : "", btn("Cancel", { variant: "ghost", href: back }))}${edit.problem ? notice(edit.problem) : ""}`;
95
+ return postForm("/spend/budget", body, { attrs: { class: "spend-edit", id, "data-spend-limit": "", novalidate: true }, hidden: row === null ? {} : { target: row.target } });
60
96
  }
61
- export function spendHtml(view, notice) {
62
- const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
63
- const { totals } = view.report;
97
+ function limitRow(row, view, current, open) {
98
+ const budget = row.budget;
99
+ const hot = budget !== null && limitNeedsYou(budget);
100
+ const over = budget !== null && budget.spentMicrousd >= budget.limitMicrousd;
101
+ const hint = budget === null ? "No limit." : over && budget.hardStop ? "Used up: new API work waits until next month or a higher limit."
102
+ : `${budget.hardStop ? "Pauses new API work at the limit." : "Alerts only."}${budget.unpriced > 0 ? ` ${each(budget.unpriced, "piece")} of work without a price.` : ""}`;
103
+ const fig = budget === null ? `${money(row.spentMicrousd)} so far` : `${money(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%`;
104
+ const action = open !== null ? "" : btn(budget === null ? "Set a limit" : "Edit limit", { sm: true, href: editHref(row.target, view.month, current) });
105
+ const meter = budget === null ? "" : html `<span class="spend-meter${over ? " over" : ""}" role="meter" aria-valuemin="0" aria-valuemax="${budget.limitMicrousd}" aria-valuenow="${budget.spentMicrousd}" aria-label="${row.label}: ${fig}"><i style="width:${Math.min(100, Math.max(0, budget.percent))}%"></i></span>`;
106
+ return html `<div class="spend-lim"${budget === null ? "" : html ` data-budget="${budget.id}"`}><div class="spend-lim-h"><span class="spend-lim-l"><span class="spend-lim-t">${row.label}${hot ? chip("Needs you", "needs") : ""}</span><span class="spend-lim-x">${hint}</span></span>\
107
+ <span class="spend-fig">${fig}</span><span class="spend-lim-a">${action}</span></div>${meter}${open === null ? "" : limitForm(row, open, view, undefined)}</div>`;
108
+ }
109
+ export function spendHtml(view, said) {
110
+ const current = view.current ?? view.month;
64
111
  const nameOfSubagent = (key) => view.subagentNames.get(Number(key)) ?? `Subagent ${key}`;
112
+ const edit = view.edit ?? null;
113
+ const saved = (view.budgets ?? []).map(budget => ({ target: `${budget.scope}:${budget.key}`, budget, spentMicrousd: budget.spentMicrousd,
114
+ label: budget.scope === "installation" ? "Everything" : budget.scope === "project" ? projectName(budget.key) : budgetLabel(budget, budget.scope === "subagent" ? nameOfSubagent(budget.key) : undefined).replace(/'s$/, "") }));
115
+ // The limit Settings flags (Needs you) sits first; then the saved limits, then what has none yet.
116
+ const rows = [...saved.filter(one => one.budget !== null && limitNeedsYou(one.budget)), ...saved.filter(one => one.budget === null || !limitNeedsYou(one.budget)), ...(view.unlimited ?? [])];
117
+ const openFor = (row) => edit !== null && edit.target === row.target ? edit : null;
118
+ const targets = view.targets ?? [];
119
+ const adding = edit !== null && !rows.some(row => row.target === edit.target) && targets.length > 0 ? edit : null;
120
+ const add = targets.length === 0 ? "" : html `<div class="spend-lim"><div class="spend-lim-h"><span class="spend-lim-l"><span class="spend-lim-t">A person or a subagent</span><span class="spend-lim-x">A limit for one person’s or one subagent’s work.</span></span><span></span>\
121
+ <span class="spend-lim-a">${adding === null ? btn("Add a limit", { sm: true, href: editHref("new", view.month, current) }) : ""}</span></div>${adding === null ? "" : limitForm(null, adding, view, targets)}</div>`;
122
+ const status = said.problem ? notice(said.problem) : said.said ? html `<p class="k-note" role="status">${said.said}</p>` : "";
123
+ const limits = view.budgets === null ? "" : sec("limits", "Monthly limits", monthWords(current, "long").split(" ")[0], html `${status}${rows.map(row => limitRow(row, view, current, openFor(row)))}${add}`);
124
+ const { totals } = view.report;
65
125
  const usageValue = totals.usageCount === 0 ? "None" : totals.usageMicrousd > 0 ? money(totals.usageMicrousd) : "Not reported";
66
126
  const usageMeta = totals.usageCount === 0 ? "No plan usage recorded"
67
127
  : totals.usageMicrousd > 0 ? `of usage at API prices${totals.usageUnreported > 0 ? ` · ${each(totals.usageUnreported, "piece")} of work not reported` : ""}` : `${each(totals.usageCount, "piece")} of work`;
68
128
  const unknownValue = totals.unknown === 0 ? "None" : String(totals.unknown);
69
129
  const unknownMeta = [totals.unknown > 0 ? `${totals.unknown === 1 ? "piece" : "pieces"} of work with billing not proved or cost not recorded` : "Nothing with an unknown cost",
70
130
  ...(totals.estimatedMicrousd > 0 ? [`counted as ${money(totals.estimatedMicrousd)} (estimated)`] : [])].join(" · ");
71
- const figures = html `<section class="spend-figures" aria-label="This month">\
72
- <div class="spend-figure" data-amount-kind="api" data-microusd="${totals.apiMicrousd}"><h2>API dollars</h2><p class="value">${money(totals.apiMicrousd)}</p><p class="meta">Recorded charges to API keys</p></div>\
73
- <div class="spend-figure" data-amount-kind="subscription" data-microusd="${totals.usageMicrousd}" data-unreported="${totals.usageUnreported}"><h2>Plan usage</h2><p class="value">${usageValue}</p><p class="meta">${usageMeta}</p></div>\
74
- <div class="spend-figure" data-amount-kind="unknown" data-count="${totals.unknown}" data-estimated-microusd="${totals.estimatedMicrousd}"><h2>Unknown</h2><p class="value">${unknownValue}</p><p class="meta">${unknownMeta}</p></div></section>`;
75
- const limits = view.budgets === null ? "" : html `<section><h2>Monthly limits</h2>${view.budgets.length === 0 ? html `<p class="meta">No monthly limits. <a href="/settings/spend">Set a limit</a></p>`
76
- : html `${view.budgets.map(budget => limitRow(budget, nameOfSubagent, ""))}<p class="meta"><a href="/settings/spend">Change limits</a> · Evals never count toward a limit.</p>`}${view.windows}</section>`;
77
- const runs = view.report.topRuns.length === 0 ? "" : html `<section><h2>Most expensive runs</h2><table><thead><tr><th>Task</th><th>Run</th><th>Model</th><th class="n">API dollars</th></tr></thead><tbody>${view.report.topRuns.map(run => html `<tr data-run="${run.runId}"><td><a href="${taskHref(run.taskId)}">${run.taskId}</a>${run.project === null ? "" : html ` <span class="meta">${projectName(run.project)}</span>`}</td><td><a href="${runHref(run.runId)}">${ROLE_WORDS[run.role]} ${run.runId}</a></td><td>${run.provider} · ${run.model}</td><td class="n">${money(run.microusd)}</td></tr>`)}</tbody></table></section>`;
78
- const notCounted = html `<section class="spend-not-counted" data-not-counted><h2>Not counted here</h2><ul>${NOT_COUNTED.map(one => html `<li>${one}</li>`)}</ul></section>`;
79
- return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend?month=${view.previous}">← ${view.previous}</a><strong>${view.month}</strong>${view.next === null ? "" : html `<a href="/spend?month=${view.next}">${view.next} →</a>`}<span class="meta">UTC month</span><a href="/spend?month=${view.month}&amp;format=csv" download>CSV</a></p></div>${note}${figures}${limits}${linesTable("By project", view.report.byProject, projectName)}${linesTable("By model", view.report.byModel, key => key, 12, line => line.chosen.length === 0 ? "" : `chosen as ${line.chosen.join(", ")}`)}${linesTable("By role", view.report.byRole, key => ROLE_WORDS[key] ?? key)}${linesTable("By person", view.report.byPerson, key => key)}${runs}${notCounted}</article>`;
131
+ const label = monthWords(view.month, "long");
132
+ const figures = html `<section class="spend-sum" aria-label="${label}">\
133
+ <div data-amount-kind="api" data-microusd="${totals.apiMicrousd}"><span class="spend-sum-k">API dollars · ${label}${view.month === current ? " so far" : ""}</span><span class="spend-sum-v">${money(totals.apiMicrousd)}</span><span class="spend-sum-x">Recorded charges to API keys</span></div>\
134
+ <div data-amount-kind="subscription" data-microusd="${totals.usageMicrousd}" data-unreported="${totals.usageUnreported}"><span class="spend-sum-k">Plan usage</span><span class="spend-sum-v">${usageValue}</span><span class="spend-sum-x">${usageMeta}</span></div>\
135
+ <div data-amount-kind="unknown" data-count="${totals.unknown}" data-estimated-microusd="${totals.estimatedMicrousd}"><span class="spend-sum-k">Unknown</span><span class="spend-sum-v">${unknownValue}</span><span class="spend-sum-x">${unknownMeta}</span></div></section>`;
136
+ const monthItems = [{ key: view.previous, label: monthWords(view.previous, "short"), href: `/spend?month=${view.previous}` }, { key: view.month, label: monthWords(view.month, "short"), href: `/spend?month=${view.month}` },
137
+ ...(view.next === null ? [] : [{ key: view.next, label: monthWords(view.next, "short"), href: `/spend?month=${view.next}` }])];
138
+ const monthBar = html `<div class="spend-month">${segLinks("Month", view.month, monthItems)}${btn("Download CSV", { variant: "ghost", sm: true, href: `/spend?month=${view.month}&format=csv`, attrs: { download: "" } })}</div>`;
139
+ const nothing = view.report.byProject.length === 0 && view.report.byModel.length === 0 && totals.unknown === 0 && totals.usageCount === 0;
140
+ const max = Math.max(0, ...view.report.byProject.map(line => line.apiMicrousd), ...view.report.byModel.map(line => line.apiMicrousd));
141
+ const runs = view.report.topRuns.length === 0 ? "" : html `<div class="spend-runs">${view.report.topRuns.map(run => html `<div class="spend-run" data-run="${run.runId}"><span><a class="k-link" href="${taskHref(run.taskId)}">${run.taskId}</a> · <a class="k-link" href="${runHref(run.runId)}">${ROLE_WORDS[run.role]} ${run.runId}</a></span>\
142
+ <span class="k-num">${money(run.microusd)}</span><small>${run.project === null ? "" : `${projectName(run.project)} · `}<span class="k-mono">${run.provider} · ${run.model}</span></small></div>`)}</div>`;
143
+ const more = view.report.byRole.length === 0 && view.report.byPerson.length === 0 && runs === "" ? "" : disclose("By role, by person and the costliest runs", html `${view.report.byRole.length === 0 ? "" : bars("Role", view.report.byRole, key => ROLE_WORDS[key] ?? key, max)}${view.report.byPerson.length === 0 ? "" : bars("Person", view.report.byPerson, key => key, max)}${runs}`);
144
+ const breakdowns = nothing ? empty({ title: `Nothing spent in ${label}.` }) : html `${view.report.byProject.length === 0 ? "" : sec("by-project", "By project", "", bars("Project", view.report.byProject, projectName, max))}${view.report.byModel.length === 0 ? "" : sec("by-model", "By model", "", bars("Model", view.report.byModel, key => key, max, 12, line => line.chosen.length === 0 ? "" : `chosen as ${line.chosen.join(", ")}`))}${more}`;
145
+ const windows = view.windows === null || view.windows.tiles.length === 0 ? "" : sec("windows", "Plan windows", "", html `<div class="spend-wins">${view.windows.tiles.map(tile => html `<div class="spend-win ${tile.tone}" data-limit="${tile.key}"${tile.title === null ? "" : html ` title="${tile.title}"`}>\
146
+ <div class="spend-win-h"><b>${tile.name} ${tile.window}</b><span class="k-num">${tile.value}${tile.unit}</span></div><span class="spend-meter" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(tile.percent)}" aria-label="${tile.name} ${tile.window}: ${tile.value}${tile.unit}"><i style="width:${Math.min(100, Math.max(0, tile.percent))}%"></i></span><span class="k-note">${tile.detail}</span></div>`)}</div>`);
147
+ const notCounted = html `<p class="k-note" data-not-counted>Not counted here: ${NOT_COUNTED.map(one => one.charAt(0).toLowerCase() + one.slice(1)).join("; ")}. Evals never count toward a limit.</p>`;
148
+ const docked = edit !== null && (rows.some(row => row.target === edit.target) || adding !== null)
149
+ ? dock(btn("Save limit", { variant: "primary", type: "submit", attrs: { form: `limit-${edit.target.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60) || "new"}`, name: "action", value: "save" } }))
150
+ : "";
151
+ const body = html `<div class="spend">${limits}${monthBar}${figures}${breakdowns}${windows}${notCounted}</div>${docked}`;
152
+ return settingsShell({ group: "spend", title: "Spend", lead: "What agents cost, by project and by model, with the limits that stop them. Dates are UTC months.", body,
153
+ flags: { spend: saved.some(one => one.budget !== null && limitNeedsYou(one.budget)) } });
80
154
  }
81
- /** Settings → Spend: the monthly limits editor (moved from the Spend page) and a summary of the models in use. */
82
- export function spendSettingsHtml(view, notice) {
83
- const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
84
- const nameOfSubagent = (key) => view.subagentNames.get(Number(key)) ?? `Subagent ${key}`;
85
- const password = reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`);
86
- const budgets = view.budgets.map(budget => limitRow(budget, nameOfSubagent, html `<details><summary>Change</summary>${postForm("/spend/budget", html `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label><label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? html ` checked` : ""}> Pause new API work at 100%</label>${password}<button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button>`, { attrs: { class: "budget-form" }, hidden: { target: `${budget.scope}:${budget.key}` } })}</details>`));
87
- const groups = ["Everything", "Projects", "People", "Subagents"];
88
- const options = groups.map(group => {
89
- const members = view.targets.filter(one => one.group === group);
90
- return members.length === 0 ? "" : html `<optgroup label="${group}">${members.map(one => html `<option value="${one.value}">${one.label}</option>`)}</optgroup>`;
91
- });
92
- const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a limit</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Pause new API work at 100%</label>${password}<button type="submit" name="action" value="save">Add limit</button>`, { attrs: { class: "budget-form" } })}</details>`;
93
- const models = html `<section class="spend-models"><h2>Models</h2>${view.models.length === 0 ? "" : html `<dl>${view.models.map(one => html `<dt>${one.role}</dt><dd>${one.model}</dd>`)}</dl>`}<p><a href="/settings/models">Change models</a></p></section>`;
94
- return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend">See this month's spend</a></p></div>${note}<section><h2>Monthly limits</h2>${budgets.length === 0 ? html `<p class="meta">No monthly limits yet.</p>` : budgets}${add}</section>${models}</article>`;
155
+ /** Checks the amount next to its field before any password is asked or the form leaves the page; the server checks
156
+ * it again. Remove skips it. */
157
+ export function spendScript() {
158
+ return `(function(){document.addEventListener("submit",function(e){var f=e.target;if(!f||!f.matches||!f.matches("form[data-spend-limit]"))return;
159
+ var s=e.submitter;if(s&&s.hasAttribute("data-spend-remove"))return;var u=f.querySelector("[data-spend-usd]");if(!u)return;
160
+ var v=u.value.replace(/[$,\\s]/g,""),ok=/^\\d+$/.test(v)&&+v>=1&&+v<=10000000,err=document.getElementById(u.id+"-err");
161
+ if(ok){u.removeAttribute("aria-invalid");if(err)err.textContent="";u.value=v;return;}
162
+ e.preventDefault();u.setAttribute("aria-invalid","true");if(err)err.textContent="Use a whole number of dollars, at least $1, like 40. Nothing changed.";u.focus();},true);})();`;
95
163
  }
96
164
  /** One row per piece of spend, for a spreadsheet (the command line's `spend --csv`). */
97
165
  export function spendCsv(items, subagentNames) {
package/dist/sso-ui.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import type { OidcSettings } from "./oidc.js";
7
7
  import { type Html } from "./html.js";
8
- export declare const SSO_CSS: string;
8
+ /** The old card look; the page is built from the kit now. Kept for chrome.ts, which still concatenates it. */
9
+ export declare const SSO_CSS = "";
9
10
  export type SsoSettingsView = {
10
11
  settings: OidcSettings | null;
11
12
  /** The address to register with the provider as this app's sign-in redirect. */
@@ -17,7 +18,9 @@ export type SsoSettingsView = {
17
18
  /** Whether the person looking is linked to the provider already. */
18
19
  linked: boolean;
19
20
  };
20
- export declare function ssoSettingsHtml(view: SsoSettingsView, notice: {
21
+ /** Whether an address only this computer can open (localhost, 127.x, ::1): a provider can't send people back to it. */
22
+ export declare function loopbackAddress(address: string): boolean;
23
+ export declare function ssoSettingsHtml(view: SsoSettingsView, outcome: {
21
24
  said?: string | null;
22
25
  problem?: string | null;
23
26
  }): Html;
package/dist/sso-ui.js CHANGED
@@ -1,38 +1,70 @@
1
- import { html, postForm } from "./html.js";
2
- export const SSO_CSS = `.sign-in{max-width:780px;min-width:0}.sign-in form{display:grid;gap:12px;margin:0}.sign-in label{display:grid;gap:6px}.sign-in input,.sign-in select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}` +
3
- `.sign-in .card{padding:16px 18px;margin:12px 0}.sign-in .card h2{font-size:1.05rem;margin:0 0 6px}.sign-in .redirect code{user-select:all;overflow-wrap:anywhere}.sign-in button{justify-self:start;min-height:44px}` +
4
- `.sign-in .rules{display:grid;gap:8px}.sign-in .rule{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,2fr);gap:8px;align-items:start}.sign-in .rule select[multiple]{min-height:5.5rem}` +
5
- `.sign-in .row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.sign-in fieldset{border:0;padding:0;margin:0;display:grid;gap:6px}.sign-in fieldset label{display:flex;gap:8px;align-items:center}.sign-in fieldset input{width:auto}` +
6
- `@media(max-width:600px){.sign-in .rule{grid-template-columns:1fr}.sign-in input,.sign-in select{font-size:16px}}`;
7
- const password = html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`;
1
+ import { html, joinHtml, postForm } from "./html.js";
2
+ import { acts, btn, codeLine, disclose, formRow, infoLine, input, note, notice, radios, select, settingsSection } from "./server/kit.js";
3
+ import { kitSectionForm, SECRET_OPT_OUTS } from "./settings-kit.js";
4
+ /** The old card look; the page is built from the kit now. Kept for chrome.ts, which still concatenates it. */
5
+ export const SSO_CSS = "";
6
+ /** Whether an address only this computer can open (localhost, 127.x, ::1): a provider can't send people back to it. */
7
+ export function loopbackAddress(address) {
8
+ let host;
9
+ try {
10
+ host = new URL(address).hostname.toLowerCase();
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ return host === "localhost" || host.endsWith(".localhost") || /^127(\.\d{1,3}){3}$/.test(host) || host === "[::1]" || host === "::1" || host === "0.0.0.0";
16
+ }
8
17
  function ruleRow(index, rule, projects) {
9
18
  const chosen = rule === null ? [] : rule.projects === "all" ? ["all"] : rule.projects;
10
- return html `<div class="rule"><label>Group<input type="text" name="group-${index}" value="${rule?.group ?? ""}" placeholder="${index === 0 ? "for example: engineering" : ""}" autocomplete="off"></label>\
11
- <label>Role<select name="role-${index}"><option value="operator"${rule?.role === "operator" ? " selected" : ""}>Operator</option><option value="viewer"${rule?.role === "viewer" ? " selected" : ""}>Viewer</option></select></label>\
12
- <label>Projects<select name="projects-${index}" multiple><option value="all"${chosen.includes("all") ? " selected" : ""}>All projects</option>${projects.map(one => html `<option value="${one.path}"${chosen.includes(one.path) ? " selected" : ""}>${one.name}</option>`)}</select></label></div>`;
19
+ const id = `sso-rule-${index}`;
20
+ const box = (value, label, at) => html `<label for="${id}-p${at}"><input type="checkbox" id="${id}-p${at}" name="projects-${index}" value="${value}"${chosen.includes(value) ? html ` checked` : ""}> ${label}</label>`;
21
+ return formRow(`Rule ${index + 1}`, index === 0 ? "The first rule that matches a person decides." : null, joinHtml([
22
+ input({ id: `${id}-group`, name: `group-${index}`, value: rule?.group ?? "", attrs: { placeholder: index === 0 ? "Group, for example: engineering" : "Group", autocomplete: "off", "aria-label": `Rule ${index + 1} group` } }),
23
+ select({ id: `${id}-role`, name: `role-${index}`, label: `Rule ${index + 1} role`, current: rule?.role ?? "operator", items: [["operator", "Can manage and approve"], ["viewer", "Can view"]] }),
24
+ html `<div class="k-checks" role="group" aria-label="Rule ${index + 1} projects">${box("all", "All projects", 0)}${projects.map((one, at) => box(one.path, one.name, at + 1))}</div>`,
25
+ ]), "wide");
13
26
  }
14
- export function ssoSettingsHtml(view, notice) {
27
+ export function ssoSettingsHtml(view, outcome) {
15
28
  const s = view.settings;
29
+ const answer = outcome.problem ? notice(outcome.problem) : outcome.said ? infoLine(outcome.said) : "";
16
30
  const rows = [...(s?.rules ?? []), null, null].slice(0, Math.max(3, (s?.rules.length ?? 0) + 1)).map((rule, index) => ruleRow(index, rule, view.projects));
17
- const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
18
31
  const redirect = view.redirect === null
19
- ? html `<p class="meta">Open this page from the address people use (your https address) to see the redirect address to register.</p>`
20
- : html `<p class="redirect">Register this redirect address with the provider: <code>${view.redirect}</code></p>`;
21
- const form = postForm("/settings/sign-in", html `\
22
- <label>Provider address (issuer)<input type="url" name="issuer" value="${s?.issuer ?? ""}" placeholder="https://acme.okta.com/oauth2/default" required inputmode="url" autocomplete="off"></label>\
23
- <label>Client ID<input type="text" name="client-id" value="${s?.clientId ?? ""}" required autocomplete="off"></label>\
24
- <label>Client secret<input type="password" name="client-secret" autocomplete="off" placeholder="${s?.clientSecret ? "Saved. Leave blank to keep it" : "Leave blank for a public client"}"></label>\
25
- <details><summary>More</summary><label>Button says “Sign in with …”<input type="text" name="label" value="${s?.label ?? ""}" placeholder="Okta"></label>\
26
- <label>Scopes<input type="text" name="scopes" value="${s?.scopes ?? "openid email profile"}"></label><label>Groups claim<input type="text" name="groups-claim" value="${s?.groupsClaim ?? "groups"}"></label></details>\
27
- <h2>Who may sign in</h2><p class="meta">A person's first matching group sets their role and projects, again at every sign-in. No match, no entry. Use * for everyone.</p><div class="rules">${rows}</div>\
28
- <fieldset><legend>Passwords</legend><label><input type="radio" name="passwords" value="everyone"${s?.passwords !== "operators" ? " checked" : ""}> Everyone may still use a password</label>\
29
- <label><input type="radio" name="passwords" value="operators"${s?.passwords === "operators" ? " checked" : ""}> Only instance operators (a way in if the provider is down)</label></fieldset>\
30
- ${password}<button>${s === null ? "Turn on sign-in with the provider" : "Save"}</button>`, { hidden: { action: "save" } });
31
- const test = s === null ? "" : postForm("/settings/sign-in", html `<button class="secondary">Check the provider</button>`, { attrs: { class: "row" }, hidden: { action: "test" } });
32
- const link = s === null ? "" : view.linked
33
- ? html `<p class="meta">You sign in with ${s.label}.</p>`
34
- : html `<section class="card"><h2>Your account</h2><p>Sign in with ${s.label} as this account from now on.</p><p><a class="button-link" href="/login/sso?link=1">Link to ${s.label}</a></p></section>`;
35
- const off = s === null ? "" : html `<details class="card"><summary>Turn off</summary>${postForm("/settings/sign-in", html `<p>People who signed in with ${s.label} can't sign in until it's on again, unless they have a password.</p>${password}<button class="danger">Turn off sign-in with ${s.label}</button>`, { hidden: { action: "remove" } })}</details>`;
36
- return html `<section class="sign-in">${note}<p>People sign in with your organisation's identity provider (Okta, Microsoft Entra, Google, anything that speaks OpenID Connect), and its groups decide what they may do.</p>\
37
- ${redirect}<section class="card">${form}</section>${test}${link}${off}</section>`;
32
+ ? note("Open this page from the address people use (your https address) to see the redirect address to register.")
33
+ : joinHtml([
34
+ formRow("Redirect address", "Register this with the provider as the sign-in redirect.", codeLine(view.redirect), "wide"),
35
+ loopbackAddress(view.redirect)
36
+ ? infoLine("This page is open on a local address, which only this computer can reach, so a provider can’t send people back to it. Open this page from the address people use (your https address) and register that one instead.")
37
+ : "",
38
+ ]);
39
+ const check = s === null ? "" : postForm("/settings/sign-in", acts(btn("Check the provider", { sm: true, type: "submit" })), { hidden: { action: "test" } });
40
+ const provider = settingsSection({ id: "provider", title: "Provider", state: s === null ? " · off" : ` · ${s.label}`,
41
+ lead: "People sign in with your organisation’s identity provider (Okta, Microsoft Entra, Google, anything that speaks OpenID Connect), and its groups decide what they may do.",
42
+ body: joinHtml([redirect,
43
+ kitSectionForm({ proof: "sign-in", action: "/settings/sign-in", id: "sign-in-form", gate: s !== null, hidden: { action: "save" },
44
+ save: { label: s === null ? "Turn on sign-in with the provider" : "Save" }, confirm: s === null ? "Turn on sign-in with the provider" : "Change sign-in with the provider",
45
+ body: joinHtml([
46
+ formRow("Provider address (issuer)", null, input({ id: "sso-issuer", name: "issuer", type: "url", value: s?.issuer ?? "", attrs: { placeholder: "https://acme.okta.com/oauth2/default", required: true, inputmode: "url", autocomplete: "off" } })),
47
+ formRow("Client ID", null, input({ id: "sso-client-id", name: "client-id", value: s?.clientId ?? "", attrs: { required: true, autocomplete: "off" } })),
48
+ formRow("Client secret", s?.clientSecret ? "Saved. Leave blank to keep it." : "Leave blank for a public client.", input({ id: "sso-client-secret", name: "client-secret", type: "password", attrs: { autocomplete: "off", ...SECRET_OPT_OUTS, "aria-label": "Client secret" } })),
49
+ disclose("More", joinHtml([
50
+ formRow("Button says “Sign in with …”", null, input({ id: "sso-label", name: "label", value: s?.label ?? "", attrs: { placeholder: "Okta" } })),
51
+ formRow("Scopes", null, input({ id: "sso-scopes", name: "scopes", value: s?.scopes ?? "openid email profile" })),
52
+ formRow("Groups claim", null, input({ id: "sso-groups-claim", name: "groups-claim", value: s?.groupsClaim ?? "groups" })),
53
+ ])),
54
+ formRow("Who may sign in", "A person’s first matching group sets their role and projects, again at every sign-in. No match, no entry. Use * for everyone.", html ``, "wide"),
55
+ ...rows,
56
+ formRow("Passwords", null, radios({ name: "passwords", legend: "Passwords", current: s?.passwords === "operators" ? "operators" : "everyone", items: [
57
+ { value: "everyone", label: "Everyone may still use a password" },
58
+ { value: "operators", label: "Only instance operators", text: "A way in if the provider is down." }
59
+ ] }), "wide"),
60
+ ]) }),
61
+ check,
62
+ ]) });
63
+ const account = s === null ? "" : settingsSection({ id: "your-account", title: "Your account",
64
+ body: view.linked ? note(`You sign in with ${s.label}.`)
65
+ : joinHtml([note(`Sign in with ${s.label} as this account from now on.`), acts(btn(`Link to ${s.label}`, { sm: true, href: "/login/sso?link=1" }))]) });
66
+ const off = s === null ? "" : settingsSection({ id: "turn-off", title: "Turn off",
67
+ body: kitSectionForm({ proof: "sign-in", action: "/settings/sign-in", id: "sign-in-off", hidden: { action: "remove" }, save: { label: `Turn off sign-in with ${s.label}`, variant: "danger" },
68
+ confirm: `Turn off sign-in with ${s.label}`, body: note(`People who signed in with ${s.label} can’t sign in until it’s on again, unless they have a password.`) }) });
69
+ return joinHtml([answer, provider, account, off]);
38
70
  }
@@ -3,7 +3,8 @@ import { type CheckoutPlan } from "./checkout-cleanup.js";
3
3
  import { type SweepRecord } from "./storage-sweep.js";
4
4
  import { type SweepSchedule } from "./storage-sweep-schedule.js";
5
5
  import type { LandedSummary } from "./landed-cleanup.js";
6
- export declare const STORAGE_CSS: string;
6
+ /** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
7
+ export declare const STORAGE_CSS = "";
7
8
  export type StorageView = {
8
9
  plan: CheckoutPlan;
9
10
  csrf: string;
@@ -16,7 +17,7 @@ export type StorageView = {
16
17
  /** When the checkouts were last measured, and its Check now (docs/plans/get-side-effects.md). */
17
18
  measured?: Html;
18
19
  };
19
- export declare function storageHtml(view: StorageView, notice: {
20
+ export declare function storageHtml(view: StorageView, said: {
20
21
  said?: string | null;
21
22
  problem?: string | null;
22
23
  }): Html;