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.
- package/CHANGELOG.md +59 -0
- package/dist/agents-board.d.ts +12 -0
- package/dist/agents-board.js +26 -0
- package/dist/approval-rules-ui.d.ts +4 -1
- package/dist/approval-rules-ui.js +31 -15
- package/dist/assignment-ui.js +1 -1
- package/dist/backup-ui.d.ts +5 -4
- package/dist/backup-ui.js +65 -32
- package/dist/browser/terminal.css +1 -1
- package/dist/browser/terminal.js +57 -54
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +35 -70
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +329 -183
- package/dist/browser-workspace.js +10 -9
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-settings-page.d.ts +29 -9
- package/dist/chat-settings-page.js +59 -28
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +67 -58
- package/dist/contracts/cli.d.ts +43 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/credentials-ui.d.ts +5 -3
- package/dist/credentials-ui.js +58 -31
- package/dist/design/kit.css +685 -0
- package/dist/design/kit.d.ts +208 -0
- package/dist/design/kit.js +177 -0
- package/dist/design/tokens.css +19 -1
- package/dist/discord-settings.js +8 -4
- package/dist/export-ui.d.ts +6 -4
- package/dist/export-ui.js +18 -8
- package/dist/flow-changed.d.ts +14 -0
- package/dist/flow-changed.js +53 -0
- package/dist/flow-gallery-ui.d.ts +41 -4
- package/dist/flow-gallery-ui.js +95 -63
- package/dist/flow-starters-ui.d.ts +3 -9
- package/dist/flow-starters-ui.js +12 -23
- package/dist/flow-unattended-words.d.ts +20 -0
- package/dist/flow-unattended-words.js +24 -0
- package/dist/flow-unattended.d.ts +9 -5
- package/dist/flow-unattended.js +45 -23
- package/dist/flows-cli.js +7 -3
- package/dist/flows-ui.d.ts +12 -6
- package/dist/flows-ui.js +141 -30
- package/dist/integrations-ui.d.ts +6 -4
- package/dist/integrations-ui.js +34 -40
- package/dist/integrations.d.ts +2 -2
- package/dist/integrations.js +4 -4
- package/dist/kits-ui.d.ts +8 -10
- package/dist/kits-ui.js +29 -33
- package/dist/kits.js +1 -1
- package/dist/knowledge-ui.js +76 -19
- package/dist/ledger-view.d.ts +2 -0
- package/dist/ledger-view.js +41 -38
- package/dist/models-ui.d.ts +8 -3
- package/dist/models-ui.js +82 -59
- package/dist/monitoring-ui.d.ts +4 -2
- package/dist/monitoring-ui.js +44 -13
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +8 -0
- package/dist/operate-remote-arguments.js +1 -0
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +38 -4
- package/dist/passwordless.d.ts +1 -1
- package/dist/passwordless.js +1 -1
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +25 -23
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +27 -21
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +14 -1
- package/dist/project-delete-ui.d.ts +4 -2
- package/dist/project-delete-ui.js +135 -126
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +16 -3
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +39 -28
- package/dist/recipe-ui.d.ts +4 -2
- package/dist/recipe-ui.js +14 -4
- package/dist/release-adapters.d.ts +3 -0
- package/dist/release-adapters.js +22 -2
- package/dist/release-cli.js +81 -9
- package/dist/release.d.ts +21 -0
- package/dist/release.js +40 -4
- package/dist/releases.js +1 -1
- package/dist/request-budget-ui.d.ts +4 -2
- package/dist/request-budget-ui.js +30 -19
- package/dist/retention-ui.d.ts +4 -3
- package/dist/retention-ui.js +15 -12
- package/dist/scope.d.ts +3 -0
- package/dist/scope.js +6 -0
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +51 -7
- package/dist/server/agents-computers.d.ts +9 -0
- package/dist/server/agents-computers.js +67 -0
- package/dist/server/chat.js +16 -16
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +19 -9
- package/dist/server/console.css +2 -2
- package/dist/server/flows.js +64 -36
- package/dist/server/home.d.ts +35 -0
- package/dist/server/home.js +104 -0
- package/dist/server/http.d.ts +3 -0
- package/dist/server/http.js +5 -0
- package/dist/server/kit-standalone.d.ts +22 -0
- package/dist/server/kit-standalone.js +69 -0
- package/dist/server/kit.d.ts +405 -0
- package/dist/server/kit.js +361 -0
- package/dist/server/pages.d.ts +2 -1
- package/dist/server/pages.js +213 -174
- package/dist/server/people-tokens.js +26 -85
- package/dist/server/render-history.d.ts +51 -0
- package/dist/server/render-history.js +228 -0
- package/dist/server/render-new-task.d.ts +29 -0
- package/dist/server/render-new-task.js +96 -0
- package/dist/server/render-next.d.ts +63 -0
- package/dist/server/render-next.js +109 -0
- package/dist/server/render-pages.d.ts +22 -190
- package/dist/server/render-pages.js +123 -575
- package/dist/server/render-people.d.ts +24 -3
- package/dist/server/render-people.js +93 -72
- package/dist/server/render-projects.d.ts +103 -0
- package/dist/server/render-projects.js +247 -0
- package/dist/server/render-results.d.ts +5 -24
- package/dist/server/render-results.js +48 -185
- package/dist/server/render-settings.d.ts +5 -74
- package/dist/server/render-settings.js +102 -493
- package/dist/server/render-tasks.d.ts +35 -5
- package/dist/server/render-tasks.js +114 -85
- package/dist/server/route-table.js +20 -5
- package/dist/server/settings.js +276 -255
- package/dist/server/task-views.d.ts +8 -0
- package/dist/server/task-views.js +85 -2
- package/dist/server/tasks.d.ts +8 -0
- package/dist/server/tasks.js +95 -40
- package/dist/server/terminals.js +1 -1
- package/dist/server/welcome.d.ts +22 -0
- package/dist/server/welcome.js +69 -0
- package/dist/settings-groups-ui.d.ts +279 -0
- package/dist/settings-groups-ui.js +615 -0
- package/dist/settings-kit.d.ts +42 -6
- package/dist/settings-kit.js +69 -7
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +59 -7
- package/dist/slack-settings.js +12 -5
- package/dist/spend-ui.d.ts +45 -26
- package/dist/spend-ui.js +124 -56
- package/dist/sso-ui.d.ts +5 -2
- package/dist/sso-ui.js +63 -31
- package/dist/storage-ui.d.ts +3 -2
- package/dist/storage-ui.js +56 -50
- package/dist/store.d.ts +112 -3
- package/dist/store.js +292 -20
- package/dist/surface.js +5 -1
- package/dist/teams-settings.d.ts +2 -1
- package/dist/teams-settings.js +24 -41
- package/dist/telegram-settings.d.ts +0 -4
- package/dist/telegram-settings.js +52 -25
- package/dist/toolroll-update-ui.d.ts +8 -4
- package/dist/toolroll-update-ui.js +55 -45
- package/dist/tools-ui.d.ts +5 -3
- package/dist/tools-ui.js +107 -42
- package/dist/work-index.js +11 -7
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +7 -4
- package/package.json +1 -1
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings: the overview and its group pages, in the revamp kit (src/design/KIT.md; the owner-approved prototype's
|
|
3
|
+
* Settings area, docs/plans/settings-revamp.md §3). Server HTML only: every form posts to the route and fields it
|
|
4
|
+
* posted to before, under its proof inventory row (settings-proofs.ts), and the kit's section forms
|
|
5
|
+
* (settings-kit.ts kitSectionForm) save one section at a time. Nothing here reads a request or decides an act; the
|
|
6
|
+
* page that calls it has already dropped what this person can't use, and every handler proves the act again.
|
|
7
|
+
*
|
|
8
|
+
* Groups (design/kit.ts SETTINGS_GROUPS): General, Agents and models, Chat apps and alerts, Approvals and safety,
|
|
9
|
+
* Projects, People and sign-in, Spend (its own page, /spend), System.
|
|
10
|
+
*/
|
|
11
|
+
import { ACCENT_PRESETS, accentNote } from "./accent-colors.js";
|
|
12
|
+
import {} from "./browser-workspace.js";
|
|
13
|
+
import { SETTINGS_GROUP_ICONS, SETTINGS_GROUPS, plural } from "./design/kit.js";
|
|
14
|
+
import { digestTimes } from "./digest-times.js";
|
|
15
|
+
import { html, joinHtml, postForm } from "./html.js";
|
|
16
|
+
import { RESULT_SHOT_CHOICES } from "./result-shots.js";
|
|
17
|
+
import {} from "./scope.js";
|
|
18
|
+
import { qualityModeTitle } from "./quality.js";
|
|
19
|
+
import {} from "./store.js";
|
|
20
|
+
import { kitSectionForm, SECRET_OPT_OUTS } from "./settings-kit.js";
|
|
21
|
+
import { btn, chip, codeLine, disclose, formRow, infoLine, input, needsPanel, note, notice, radios, select, settingsLink, settingsLinks, settingsRecord, settingsSection, settingsShell, switchInput, } from "./server/kit.js";
|
|
22
|
+
/** The group pages this module draws (Spend is /spend; Approvals and safety is /settings/approval, its own handler). */
|
|
23
|
+
export const GROUP_PATHS = {
|
|
24
|
+
"/settings": "hub", "/settings/general": "general", "/settings/agents": "agents", "/settings/alerts": "alerts",
|
|
25
|
+
"/settings/projects": "projects", "/settings/access": "access", "/settings/system": "system",
|
|
26
|
+
};
|
|
27
|
+
const CHAT_APPS = [["telegram", "Telegram"], ["slack", "Slack"], ["discord", "Discord"], ["teams", "Teams"]];
|
|
28
|
+
const and = (names) => names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0] ?? "";
|
|
29
|
+
const groupHref = (slug) => SETTINGS_GROUPS.find(([, one]) => one === slug)?.[2] ?? "/settings";
|
|
30
|
+
const groupLabel = (slug) => SETTINGS_GROUPS.find(([, one]) => one === slug)?.[0] ?? "Settings";
|
|
31
|
+
const sentence = (text) => text === "" ? text : `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
32
|
+
/** A notice the handler sent back with the page (?said= or ?problem=), in place under the page's lead. */
|
|
33
|
+
const answered = (facts) => facts.problem ? notice(sentence(facts.problem)) : facts.said ? infoLine(sentence(facts.said)) : html ``;
|
|
34
|
+
/** What waits on this person in Settings (never a task): no builder, a chat app failing, a failed backup. Read-only. */
|
|
35
|
+
export function settingsNeeds(facts) {
|
|
36
|
+
const out = [];
|
|
37
|
+
if (facts.workers !== null && facts.approver && !facts.workers.some(one => one.tone === "ok")) {
|
|
38
|
+
out.push({ group: "agents", title: facts.workers.length === 0 ? "No builder is connected" : "No builder is connected right now",
|
|
39
|
+
text: "Approved work waits until a computer builds it. Run toolroll up on the computer with your projects.", action: ["Open builders", "/settings/agents#workers"] });
|
|
40
|
+
}
|
|
41
|
+
if (facts.telegram.failing)
|
|
42
|
+
out.push({ group: "alerts", title: "Telegram isn’t delivering", text: facts.telegram.delivery ?? "Alerts to your phone are failing.", action: ["Open Telegram", "/settings/telegram"] });
|
|
43
|
+
if (facts.backup?.failed)
|
|
44
|
+
out.push({ group: "system", title: "The last backup failed", text: facts.backup.words, action: ["Open backups", "/settings/backups"] });
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Which groups have something waiting, for the section nav's dot and the overview's rows. */
|
|
48
|
+
export function needsByGroup(items) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const item of items)
|
|
51
|
+
out[item.group] = (out[item.group] ?? 0) + 1;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
const flagsOf = (counts) => Object.fromEntries(Object.keys(counts).map(key => [key, true]));
|
|
55
|
+
// ---- the overview -----------------------------------------------------------------------------------------------
|
|
56
|
+
const themeWords = (theme) => theme === "light" ? "Light" : theme === "dark" ? "Dark" : "Match this device";
|
|
57
|
+
function agentsWords(facts) {
|
|
58
|
+
const signed = facts.providers?.find(one => one.connection !== null && one.tone === "ok") ?? facts.providers?.find(one => one.tone === "ok");
|
|
59
|
+
const builders = facts.workers === null ? null : facts.workers.filter(one => one.tone === "ok").length;
|
|
60
|
+
return [signed ? `${signed.name}: ${signed.words}` : facts.providers === null ? "Models, the lead, tools and knowledge" : "No agent signed in yet",
|
|
61
|
+
builders === null ? null : builders === 0 ? "no builder connected" : `${plural(builders, "builder", "builders")} connected`].filter(Boolean).join(" · ");
|
|
62
|
+
}
|
|
63
|
+
function alertsWords(facts) {
|
|
64
|
+
const configured = facts.messaging?.configured ?? [];
|
|
65
|
+
if (configured.length === 0)
|
|
66
|
+
return "No chat app yet: alerts stay in Toolroll";
|
|
67
|
+
const receiving = facts.messaging?.channel ?? (configured.length === 1 ? configured[0] : null);
|
|
68
|
+
const name = (id) => CHAT_APPS.find(([one]) => one === id)?.[1] ?? id;
|
|
69
|
+
return [receiving ? `${name(receiving)}: alerts go here` : "Several chat apps: choose one", facts.telegram.failing ? "Telegram is failing" : null].filter(Boolean).join(" · ");
|
|
70
|
+
}
|
|
71
|
+
function approvalWords(facts) {
|
|
72
|
+
return [facts.permission ? `New tasks: ${facts.permission.mode === "bypassPermissions" ? "Full access" : "Auto"}` : null,
|
|
73
|
+
facts.quality ? `${qualityModeTitle(facts.quality.mode)} quality` : null, "operating modes, policy and approval rules"].filter(Boolean).join(" · ");
|
|
74
|
+
}
|
|
75
|
+
function accessWords(facts) {
|
|
76
|
+
const people = facts.people === null ? null : [facts.people.approvers.length ? `${and(facts.people.approvers)} can manage and approve` : "",
|
|
77
|
+
facts.people.viewers.length ? `${and(facts.people.viewers)} can view` : ""].filter(Boolean).join(" · ");
|
|
78
|
+
return [people || "Your sessions and API tokens", facts.passwordless === null ? null : facts.passwordless ? "Passwordless on" : "Passwordless off"].filter(Boolean).join(" · ");
|
|
79
|
+
}
|
|
80
|
+
function systemWords(facts) {
|
|
81
|
+
const latest = facts.updates?.latest;
|
|
82
|
+
const version = facts.updates === null ? (facts.version === null ? null : `Toolroll ${facts.version}`)
|
|
83
|
+
: latest !== null && latest?.newer ? `Toolroll ${facts.updates.current} · ${latest.version} is available` : `Toolroll ${facts.updates.current}`;
|
|
84
|
+
return [facts.backup?.failed ? "The last backup failed" : null, version, "backups, storage, retention, monitoring"].filter(Boolean).join(" · ");
|
|
85
|
+
}
|
|
86
|
+
/** The overview: what waits on this person, then one row per group with its state. Viewers see the groups they can open. */
|
|
87
|
+
export function settingsHub(facts) {
|
|
88
|
+
const items = settingsNeeds(facts);
|
|
89
|
+
const counts = needsByGroup(items);
|
|
90
|
+
const words = {
|
|
91
|
+
general: [`Theme: ${themeWords(facts.theme).toLowerCase()}`, facts.version === null ? null : `Toolroll ${facts.version}`].filter(Boolean).join(" · "),
|
|
92
|
+
agents: agentsWords(facts),
|
|
93
|
+
alerts: alertsWords(facts),
|
|
94
|
+
approval: approvalWords(facts),
|
|
95
|
+
projects: facts.projects.length === 0 ? "No projects yet" : `${plural(facts.projects.length, "project", "projects")} · network, checks, pull requests and tools for each`,
|
|
96
|
+
access: accessWords(facts),
|
|
97
|
+
spend: "Monthly limits and the spend report",
|
|
98
|
+
system: systemWords(facts),
|
|
99
|
+
};
|
|
100
|
+
const shown = SETTINGS_GROUPS.filter(([, slug]) => slug !== "spend" || facts.operator).filter(([, slug]) => slug !== "system" || facts.operator || facts.approver);
|
|
101
|
+
const panel = needsPanel(items.map((item, index) => ({ title: item.title, text: item.text, action: btn(item.action[0], { variant: index === 0 ? "primary" : "outline", href: item.action[1] }) })));
|
|
102
|
+
return settingsShell({
|
|
103
|
+
group: "hub", title: "Settings", lead: "For the whole Toolroll.", flags: flagsOf(counts),
|
|
104
|
+
body: joinHtml([
|
|
105
|
+
answered(facts),
|
|
106
|
+
panel,
|
|
107
|
+
settingsLinks(null, shown.map(([label, slug, href]) => settingsLink({ href, title: label, status: words[slug] ?? "", icon: SETTINGS_GROUP_ICONS[slug], needs: counts[slug] ?? 0 }))),
|
|
108
|
+
]),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** The hub's old anchors, forwarded to their group (fragments never reach the server). */
|
|
112
|
+
export const HUB_ANCHORS = {
|
|
113
|
+
providers: "/settings/agents#providers", workers: "/settings/agents#workers", email: "/settings/alerts#email", "settings-chat-apps": "/settings/alerts#chat-apps",
|
|
114
|
+
"telegram-token": "/settings/alerts#telegram-token", phone: "/settings/alerts#phone", notifications: "/settings/alerts#notifications",
|
|
115
|
+
updates: "/settings/system#updates", accent: "/settings/general#appearance", appearance: "/settings/general#appearance",
|
|
116
|
+
};
|
|
117
|
+
export const HUB_ANCHOR_SCRIPT = `(function(){var m=${JSON.stringify(HUB_ANCHORS)};var h=decodeURIComponent(location.hash.slice(1));if(Object.prototype.hasOwnProperty.call(m,h))location.replace(m[h]);})();`;
|
|
118
|
+
// ---- General ----------------------------------------------------------------------------------------------------
|
|
119
|
+
const ACCENT_CHOICES = ACCENT_PRESETS.slice(0, 6);
|
|
120
|
+
export function generalGroup(facts, flags) {
|
|
121
|
+
const themeButton = (value, label) => html `<button type="submit" name="theme" value="${value}" aria-pressed="${String(facts.theme === value)}">${label}</button>`;
|
|
122
|
+
const custom = ACCENT_CHOICES.some(one => one.hex === facts.accent) ? null : facts.accent;
|
|
123
|
+
const accentNoteWords = accentNote(facts.accent);
|
|
124
|
+
const appearance = settingsSection({
|
|
125
|
+
id: "appearance", title: "Appearance", state: " · saved for this browser",
|
|
126
|
+
body: joinHtml([
|
|
127
|
+
formRow("Theme", "Light, dark, or whatever this device uses.", postForm("/settings/appearance", html `<div class="k-seg" role="group" aria-label="Theme">${themeButton("system", "Match device")}${themeButton("light", "Light")}${themeButton("dark", "Dark")}</div>`, { returnTo: "/settings/general" })),
|
|
128
|
+
kitSectionForm({ proof: "appearance", action: "/settings/appearance", id: "accent", gate: true, hidden: { return: "/settings/general" },
|
|
129
|
+
body: joinHtml([
|
|
130
|
+
formRow("Accent", "Buttons and focus. Amber always means “needs you” and can’t be changed.", select({ name: "accent", label: "Accent", current: facts.accent, items: [...ACCENT_CHOICES.map(one => [one.hex, one.id === "ink" ? "Ink (default)" : one.name]), ...(custom === null ? [] : [[custom, `Your own colour (${custom})`]])] })),
|
|
131
|
+
accentNoteWords === null ? "" : note(accentNoteWords),
|
|
132
|
+
]) }),
|
|
133
|
+
disclose("Use your own colour", kitSectionForm({ proof: "appearance", action: "/settings/appearance", id: "accent-own", gate: true, hidden: { return: "/settings/general" },
|
|
134
|
+
body: formRow("Your own colour", "Six hex digits, like #6e56cf.", input({ id: "accent-hex", name: "accent", value: custom ?? "", attrs: { placeholder: "#6e56cf", spellcheck: "false", autocomplete: "off", "aria-label": "Your own colour", pattern: "#?[0-9a-fA-F]{6}", required: true } })) }), custom !== null),
|
|
135
|
+
]),
|
|
136
|
+
});
|
|
137
|
+
const browser = settingsSection({
|
|
138
|
+
id: "this-browser", title: "This browser",
|
|
139
|
+
body: joinHtml([
|
|
140
|
+
formRow("Remembered password", "After you type your password, standard approvals skip it on this computer for 15 minutes. Forget ends that now.", facts.browser ? postForm("/session/forget-password", btn("Forget now", { sm: true, type: "submit" }), { returnTo: "/settings/general" }) : html ``, "inline"),
|
|
141
|
+
]),
|
|
142
|
+
});
|
|
143
|
+
const password = settingsSection({
|
|
144
|
+
id: "your-password", title: "Your password",
|
|
145
|
+
body: joinHtml([
|
|
146
|
+
formRow("Where it is", "If you started Toolroll with toolroll up, your password is saved on the computer running Toolroll. Toolroll never shows it in a browser. To read it, run this there:", html ``, "wide"),
|
|
147
|
+
codeLine("cat ~/.config/toolroll/up-login.txt"),
|
|
148
|
+
facts.passwordless === null ? "" : formRow("Passwordless", "Sign in once; most approvals then need no password.", btn(`${facts.passwordless ? "On" : "Off"} · Change`, { sm: true, href: "/settings/passwordless" }), "inline"),
|
|
149
|
+
]),
|
|
150
|
+
});
|
|
151
|
+
const version = facts.version === null ? "" : settingsSection({
|
|
152
|
+
id: "version", title: "Version",
|
|
153
|
+
body: joinHtml([
|
|
154
|
+
formRow("Toolroll", facts.firstResult ?? "Installed on this computer.", html `<span class="k-mono">${facts.version}</span>`, "inline"),
|
|
155
|
+
facts.operator ? formRow("Updates", null, btn("Open updates", { sm: true, variant: "ghost", href: "/settings/updates" }), "inline") : "",
|
|
156
|
+
]),
|
|
157
|
+
});
|
|
158
|
+
return settingsShell({ group: "general", title: "General", lead: "How Toolroll looks and behaves in this browser.", flags, body: joinHtml([answered(facts), appearance, browser, password, version]) });
|
|
159
|
+
}
|
|
160
|
+
// ---- Agents and models ------------------------------------------------------------------------------------------
|
|
161
|
+
const toneChip = (tone, words) => chip(words, tone === "ok" ? "done" : tone === "warn" ? "failed" : "");
|
|
162
|
+
function providerRecord(one) {
|
|
163
|
+
const form = kitSectionForm({ proof: "provider-key", action: "/settings/provider-key", id: `provider-${one.provider}`, gate: true, hidden: { provider: one.provider }, save: { label: `Save ${one.name}` },
|
|
164
|
+
body: joinHtml([
|
|
165
|
+
one.connection === null ? "" : formRow("Sign-in", `${one.connection.words}${one.connection.facts ? ` · ${one.connection.facts}` : ""}`, html `<button class="k-btn k-btn-ghost k-btn-sm" type="submit" formaction="/settings/connection/check" formnovalidate><span class="k-face">Check again</span></button>`, "inline"),
|
|
166
|
+
one.subscriptionCapable ? formRow("How it’s paid", `${one.usage} · ${one.envName}`, select({ name: "auth-mode", label: `How ${one.name} is paid`, current: one.mode, items: [["subscription", `${one.name} subscription`], ["api-key", "API key"]] })) : note(`${one.usage} · ${one.envName}`),
|
|
167
|
+
formRow(`${one.name} API key`, "Stored privately on this computer and never shown again. Saving a key needs no password; it isn’t a way into Toolroll.", html `<input class="k-input" id="key-${one.provider}" type="password" name="value" autocomplete="off" spellcheck="false" autocapitalize="none" data-so-secret data-1p-ignore data-lpignore="${SECRET_OPT_OUTS["data-lpignore"]}" data-bwignore data-form-type="other" aria-label="${one.name} API key" placeholder="${one.set ? "Paste to replace the stored key" : "Paste a key"}">`),
|
|
168
|
+
]) });
|
|
169
|
+
const remove = one.set ? kitSectionForm({ proof: "provider-key", action: "/settings/provider-key-clear", id: `provider-${one.provider}-clear`, hidden: { provider: one.provider }, save: { label: "Remove key", variant: "danger" },
|
|
170
|
+
body: note("Runs that use this API key stop until you add one again.") }) : "";
|
|
171
|
+
return settingsRecord({ title: html `${one.name} ${toneChip(one.tone, one.words)}`, sub: one.connection === null ? one.usage : `${one.connection.words}${one.connection.facts ? ` · ${one.connection.facts}` : ""}`,
|
|
172
|
+
attrs: { "data-provider": one.provider }, more: disclose(`Manage ${one.name}`, joinHtml([form, remove])) });
|
|
173
|
+
}
|
|
174
|
+
function workersSection(workers) {
|
|
175
|
+
return settingsSection({
|
|
176
|
+
id: "workers", title: "Builders", lead: "The computers that build approved work, and what each runs now.",
|
|
177
|
+
body: workers.length === 0
|
|
178
|
+
? joinHtml([note("No builder is connected. Run this on the computer with your projects:"), codeLine("toolroll up")])
|
|
179
|
+
: joinHtml([
|
|
180
|
+
...workers.map(one => settingsRecord({
|
|
181
|
+
title: html `<span class="k-mono">${one.name}</span> ${chip(one.state, one.tone === "ok" ? "done" : one.tone === "off" ? "failed" : "")}`,
|
|
182
|
+
sub: one.running.length === 0 ? "Nothing running" : joinHtml(one.running.map(task => html `<a class="k-link" href="${task.href}">${task.title}</a>${task.project === null ? "" : html ` <span class="k-muted">${task.project}</span>`}`), " · "),
|
|
183
|
+
when: html `<span class="k-num">${one.busy} of ${one.capacity}</span> running`, attrs: { "data-worker": one.name },
|
|
184
|
+
})),
|
|
185
|
+
note(html `To change how many a builder runs at once: <code class="k-mono">toolroll runner capacity <name> <n></code>`),
|
|
186
|
+
]),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
export function agentsGroup(facts, flags) {
|
|
190
|
+
const providers = facts.providers === null ? "" : settingsSection({
|
|
191
|
+
id: "providers", title: "How agents are paid for", lead: "A plan first; an API key is used when the agent is set to it, or for agents without a plan. Keys stay on this computer.",
|
|
192
|
+
body: joinHtml([...facts.providers.map(providerRecord), note(html `<a class="k-link" href="/control">Set up a project’s builder</a>`)]),
|
|
193
|
+
});
|
|
194
|
+
const links = settingsLinks("In this group", [
|
|
195
|
+
settingsLink({ href: "/settings/models", title: "Models", status: "Which model plans, builds and reviews" }),
|
|
196
|
+
settingsLink({ href: "/settings/lead", title: "Lead", status: "The assistant you chat with" }),
|
|
197
|
+
settingsLink({ href: "/settings/lead/subagents", title: "Subagents", status: "Helpers the lead looks after" }),
|
|
198
|
+
settingsLink({ href: "/settings/tools", title: "Tools", status: "What builds can use, per project" }),
|
|
199
|
+
settingsLink({ href: "/settings/skills", title: "Skills", status: "Reusable instructions for agents" }),
|
|
200
|
+
settingsLink({ href: "/settings/knowledge", title: "Knowledge", status: "What agents know about each project" }),
|
|
201
|
+
settingsLink({ href: "/settings/learning", title: "Learning", status: "Lessons kept from finished work" }),
|
|
202
|
+
]);
|
|
203
|
+
return settingsShell({
|
|
204
|
+
group: "agents", title: "Agents and models", lead: "Which agent does the work, how it’s paid for, and which model plans, builds and reviews.", flags,
|
|
205
|
+
body: joinHtml([answered(facts), providers, facts.workers === null ? "" : workersSection(facts.workers), links]),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// ---- Chat apps and alerts ---------------------------------------------------------------------------------------
|
|
209
|
+
function chatAppsSection(facts) {
|
|
210
|
+
const configured = facts.messaging?.configured ?? [];
|
|
211
|
+
const receiving = facts.messaging?.channel ?? (configured.length === 1 ? configured[0] : null);
|
|
212
|
+
return settingsSection({
|
|
213
|
+
id: "chat-apps", title: "Chat apps", lead: "Get alerts on your phone and answer from it. Each app has its own steps.",
|
|
214
|
+
body: settingsLinks(null, CHAT_APPS.map(([id, name]) => settingsLink({
|
|
215
|
+
href: `/settings/${id}`, title: name, needs: id === "telegram" && facts.telegram.failing ? 1 : 0,
|
|
216
|
+
status: facts.messaging === null ? "Set up" : !configured.includes(id) ? "Not set up" : id === "telegram" && facts.telegram.failing ? "Has a problem" : receiving === id && !facts.messaging.implicit ? "Alerts go here" : "Connected",
|
|
217
|
+
})), { label: "Chat apps" }),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function reachSection(facts) {
|
|
221
|
+
if (facts.chat === null)
|
|
222
|
+
return html ``;
|
|
223
|
+
const chat = facts.chat;
|
|
224
|
+
const form = kitSectionForm({ proof: "notifications", action: "/settings/notifications", id: "reach-form", gate: true,
|
|
225
|
+
body: joinHtml([
|
|
226
|
+
formRow("Chat messages", null, radios({ name: "mode", legend: "Chat messages", current: chat.mode, items: [
|
|
227
|
+
{ value: "quiet", label: "Only when I’m needed", text: "One message per task, updated as it moves." },
|
|
228
|
+
{ value: "all", label: "Every step", text: "A new message for each update." }
|
|
229
|
+
] }), "wide"),
|
|
230
|
+
formRow("Evening digest", "One message: what finished, what waits, what failed.", select({ name: "digest", label: "Evening digest", current: chat.digestAt ?? "off", items: digestTimes(chat.digestAt) })),
|
|
231
|
+
formRow("Screenshots with results", null, select({ name: "screenshots", label: "Screenshots with results", current: chat.screenshots ?? "off", items: RESULT_SHOT_CHOICES })),
|
|
232
|
+
]) });
|
|
233
|
+
const projects = (chat.projects ?? []).length === 0 ? "" : joinHtml([
|
|
234
|
+
...chat.projects.map(one => kitSectionForm({ proof: "notifications-mute", action: "/settings/notifications/mute", id: `mute-${one.repo.replace(/[^a-z0-9]+/gi, "-")}`, gate: true, hidden: { repo: one.repo },
|
|
235
|
+
body: formRow(one.name, one.muted ? "Muted: no pings" : "Pings on", switchInput({ id: `pings-${one.repo.replace(/[^a-z0-9]+/gi, "-")}`, name: "pings", label: "Pings", on: !one.muted, attrs: { "aria-label": `Pings for ${one.name}` } }), "inline") })),
|
|
236
|
+
note("Muted projects still show in Tasks and the evening digest."),
|
|
237
|
+
]);
|
|
238
|
+
return settingsSection({ id: "notifications", title: "What reaches you", body: joinHtml([form, projects]) });
|
|
239
|
+
}
|
|
240
|
+
function serviceSection(facts) {
|
|
241
|
+
const services = facts.messaging;
|
|
242
|
+
if (services === null || services.configured.length < 2 && !services.implicit)
|
|
243
|
+
return html ``;
|
|
244
|
+
const name = (id) => CHAT_APPS.find(([one]) => one === id)?.[1] ?? id;
|
|
245
|
+
return settingsSection({ id: "alert-service", title: "Where alerts go", lead: "Alerts go through one app, so you are never notified twice. Telegram still takes taps and replies when another app sends alerts.",
|
|
246
|
+
body: kitSectionForm({ proof: "messaging", action: "/settings/messaging", id: "messaging-form", gate: true,
|
|
247
|
+
body: joinHtml([services.implicit ? note("Several are connected and none was chosen. Pick one.") : "",
|
|
248
|
+
radios({ name: "primary", legend: "Where alerts go", current: services.channel ?? "", items: services.configured.map(one => ({ value: one, label: name(one), text: one === "telegram" ? "Answer buttons and replies" : "Messages with links; acting stays here" })) })]) }) });
|
|
249
|
+
}
|
|
250
|
+
function digestSection(facts) {
|
|
251
|
+
if (facts.digest === null)
|
|
252
|
+
return html ``;
|
|
253
|
+
return settingsSection({ id: "telegram-digest", title: "Telegram digest", lead: "Bundle routine updates. Anything that needs you still arrives at once.",
|
|
254
|
+
body: kitSectionForm({ proof: "telegram-digest", action: "/settings/telegram-digest", id: "digest-form", gate: true, said: facts.digest.held,
|
|
255
|
+
body: formRow("Send a digest", null, select({ name: "every", label: "Send a digest", current: facts.digest.every, items: [["off", "Off: send each update"], ["30", "Every 30 minutes"], ["60", "Every hour"], ["240", "Every 4 hours"], ["720", "Every 12 hours"], ["1440", "Once a day"]] })) }) });
|
|
256
|
+
}
|
|
257
|
+
function pushSection(facts) {
|
|
258
|
+
if (facts.push === null)
|
|
259
|
+
return html ``;
|
|
260
|
+
const push = facts.push;
|
|
261
|
+
const enrol = push.available
|
|
262
|
+
? kitSectionForm({ proof: "push-subscribe", action: "/push/subscribe", id: "push-form", hidden: { endpoint: "", p256dh: "", auth: "" }, save: { label: "Get alerts on this device", variant: "outline" }, native: true,
|
|
263
|
+
confirm: "Get alerts on this device", body: html `<p class="k-note" id="push-state" aria-live="polite"></p>` })
|
|
264
|
+
: note("Alerts need a secure (https) address for this app.");
|
|
265
|
+
return settingsSection({ id: "this-device", title: "This device", lead: "A notification when something needs you. On iPhone, add this app to your Home Screen first.",
|
|
266
|
+
body: joinHtml([
|
|
267
|
+
...push.devices.map(one => settingsRecord({ title: one.words, sub: one.state === "ok" ? "" : one.state === "failing" ? "Failing" : "Expired",
|
|
268
|
+
action: one.removable ? postForm("/push/remove", btn(`Remove ${one.words.split(" · ")[0] ?? "device"}`, { sm: true, variant: "ghost", type: "submit" }), { hidden: { id: one.id } }) : "" })),
|
|
269
|
+
enrol,
|
|
270
|
+
]) });
|
|
271
|
+
}
|
|
272
|
+
/** Enrolling this device fills the subscription the form posts; it never reads the password beside it. With the kit's
|
|
273
|
+
* section forms (section-forms.ts) it answers their `k-prepare` hook after the password is confirmed, so the post
|
|
274
|
+
* goes exactly once under the pending guard; without them it holds the plain submit, then posts. */
|
|
275
|
+
export function pushScriptOf(facts) {
|
|
276
|
+
if (facts.push === null || !facts.push.available)
|
|
277
|
+
return null;
|
|
278
|
+
return `(function(){` +
|
|
279
|
+
`if(!("serviceWorker" in navigator)||!("PushManager" in window))return;` +
|
|
280
|
+
`var link=document.createElement("link");link.rel="manifest";link.href="/manifest.webmanifest";document.head.appendChild(link);` +
|
|
281
|
+
`navigator.serviceWorker.register("/sw.js",{scope:"/"}).catch(function(){});` +
|
|
282
|
+
`function fill(form){var state=document.getElementById("push-state");` +
|
|
283
|
+
`return Notification.requestPermission().then(function(granted){` +
|
|
284
|
+
`if(granted!=="granted")return "Notifications are blocked for this site in the browser settings.";` +
|
|
285
|
+
`return fetch("/push/key").then(function(r){return r.json();}).then(function(d){` +
|
|
286
|
+
`return navigator.serviceWorker.ready.then(function(reg){` +
|
|
287
|
+
`return reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:Uint8Array.from(atob(d.key.replace(/-/g,"+").replace(/_/g,"/")),function(c){return c.charCodeAt(0);})});});` +
|
|
288
|
+
`}).then(function(sub){var raw=sub.toJSON();` +
|
|
289
|
+
`form.querySelector("[name=endpoint]").value=sub.endpoint;` +
|
|
290
|
+
`form.querySelector("[name=p256dh]").value=(raw.keys.p256dh||"").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");` +
|
|
291
|
+
`form.querySelector("[name=auth]").value=(raw.keys.auth||"").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");` +
|
|
292
|
+
`return null;});` +
|
|
293
|
+
`}).catch(function(){return "Couldn’t subscribe: the browser said no.";}).then(function(problem){if(problem&&state)state.textContent=problem;return problem;});}` +
|
|
294
|
+
`function wire(){var form=document.getElementById("push-form");if(!form||form.dataset.pushWired)return;form.dataset.pushWired="1";` +
|
|
295
|
+
`form.setAttribute("data-k-prepare","");` +
|
|
296
|
+
`form.addEventListener("k-prepare",function(e){fill(form).then(function(problem){e.detail.done(problem);});});` +
|
|
297
|
+
`form.addEventListener("submit",function(event){if(document.documentElement.classList.contains("k-js")||form.dataset.ready==="1")return;` +
|
|
298
|
+
`event.preventDefault();fill(form).then(function(problem){if(problem)return;form.dataset.ready="1";form.submit();});});}` +
|
|
299
|
+
`wire();window.addEventListener("standing-orders:workspace-rendered",wire);` +
|
|
300
|
+
`})();`;
|
|
301
|
+
}
|
|
302
|
+
function emailSection(email) {
|
|
303
|
+
const google = email.google.connected;
|
|
304
|
+
const status = google !== null ? `${google}, through Google` : email.set ? `From ${email.from} through ${email.host}${email.imapHost === "" ? "" : ` · reads ${email.imapHost}`}` : "Not set up";
|
|
305
|
+
const smtp = kitSectionForm({ proof: "email", action: "/settings/email", id: "email-form", gate: true, save: { label: "Save email" },
|
|
306
|
+
body: joinHtml([
|
|
307
|
+
note("For Gmail: smtp.gmail.com, port 587, your address, and an app password. To read mail too, add imap.gmail.com."),
|
|
308
|
+
formRow("Mail server", null, input({ id: "email-host", name: "host", value: email.host, attrs: { placeholder: "smtp.gmail.com", required: true, "aria-label": "Mail server" } })),
|
|
309
|
+
formRow("Port", null, input({ id: "email-port", name: "port", type: "number", value: String(email.port), attrs: { min: 1, max: 65535, required: true, "aria-label": "Port" } })),
|
|
310
|
+
formRow("Send from", null, input({ id: "email-from", name: "from", type: "email", value: email.from, attrs: { placeholder: "you@example.com", required: true, "aria-label": "Send from" } })),
|
|
311
|
+
formRow("Username", "Usually the same address.", input({ id: "email-user", name: "user", value: email.user, attrs: { autocomplete: "off", "aria-label": "Username" } })),
|
|
312
|
+
formRow("Mail server password", email.set ? "Leave empty to keep the saved one." : "An app password.", html `<input class="k-input" id="email-password" type="password" name="password" autocomplete="off" data-so-secret data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other" aria-label="Mail server password">`),
|
|
313
|
+
formRow("Use SSL from the start", "Port 465.", switchInput({ id: "email-secure", name: "secure", label: "SSL", on: email.secure, attrs: { "aria-label": "Use SSL from the start" } }), "inline"),
|
|
314
|
+
formRow("Read mail from", "Optional.", input({ id: "email-imap", name: "imapHost", value: email.imapHost, attrs: { placeholder: "imap.gmail.com", "aria-label": "Read mail from" } })),
|
|
315
|
+
formRow("Reading port", null, input({ id: "email-imap-port", name: "imapPort", type: "number", value: String(email.imapPort), attrs: { min: 1, max: 65535, "aria-label": "Reading port" } })),
|
|
316
|
+
]) });
|
|
317
|
+
const tests = email.set ? postForm("/settings/email-test", html `<div class="k-acts">${btn("Send a test email", { sm: true, type: "submit" })}${email.imapHost === "" ? "" : btn("Check the inbox", { sm: true, type: "submit", attrs: { formaction: "/settings/email-read-test" } })}</div>`) : "";
|
|
318
|
+
const googleConnected = google === null ? "" : postForm("/settings/google/disconnect", joinHtml([
|
|
319
|
+
note(html `Signed in with Google as <b>${google}</b>. Mail is sent and read through it.`),
|
|
320
|
+
html `<div class="k-acts">${btn("Send a test email", { sm: true, type: "submit", attrs: { formaction: "/settings/email-test" } })}${btn("Check the inbox", { sm: true, type: "submit", attrs: { formaction: "/settings/email-read-test" } })}${btn("Disconnect Google", { sm: true, variant: "ghost", type: "submit" })}</div>`,
|
|
321
|
+
]));
|
|
322
|
+
const googleSetup = google !== null ? "" : disclose("Or sign in with a Google account", kitSectionForm({ proof: "google", action: "/settings/google", id: "google-form", save: { label: "Connect Google" }, native: true,
|
|
323
|
+
body: joinHtml([
|
|
324
|
+
html `<ol class="k-note"><li>In Google Cloud Console, make an OAuth client of type Web application, and set the consent screen to In production (while it’s in Testing, Google ends the connection after 7 days).</li>\
|
|
325
|
+
${email.google.redirect === null ? html `<li>Open these settings on this computer (localhost) or at your https address to see the redirect address to add.</li>` : html `<li>Add this as an authorized redirect address: <code class="k-mono" data-google-redirect>${email.google.redirect}</code></li>`}\
|
|
326
|
+
<li>Paste the client ID and secret here, then connect. Google warns that it hasn’t verified the app: it’s your own, so continue.</li></ol>`,
|
|
327
|
+
formRow("Client ID", null, input({ id: "google-id", name: "clientId", value: email.google.clientId, attrs: { placeholder: "….apps.googleusercontent.com", required: true, "aria-label": "Client ID" } })),
|
|
328
|
+
formRow("Client secret", email.google.clientId !== "" ? "Leave empty to keep the saved one." : null, html `<input class="k-input" id="google-secret" type="password" name="clientSecret" autocomplete="off" data-so-secret data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other" aria-label="Client secret">`),
|
|
329
|
+
]) }), email.google.clientId !== "" && !email.set);
|
|
330
|
+
return settingsSection({ id: "email", title: "Email", state: ` · ${status}`, lead: "Send email steps send from this account, and Email inbox triggers read it.",
|
|
331
|
+
body: google !== null ? googleConnected : joinHtml([disclose(email.set ? "Change the mail server" : "Set up a mail server", joinHtml([smtp, tests]), !email.set && email.google.clientId === ""), googleSetup]) });
|
|
332
|
+
}
|
|
333
|
+
function telegramTokenSection(facts) {
|
|
334
|
+
return settingsSection({ id: "telegram-token", title: "Telegram bot token", state: ` · ${facts.telegram.state}`,
|
|
335
|
+
body: joinHtml([
|
|
336
|
+
note(`Current: ${facts.telegram.current}`),
|
|
337
|
+
facts.telegram.delivery === null ? "" : html `<p class="k-note" data-telegram-delivery>${facts.telegram.delivery}</p>`,
|
|
338
|
+
kitSectionForm({ proof: "telegram-token", action: "/settings/telegram-token", id: "telegram-token-form", gate: true, save: { label: "Save token" },
|
|
339
|
+
body: formRow("Token from @BotFather", "Stored privately on this computer. Then pair your phone on the Telegram page.", html `<input class="k-input" id="telegram-token-field" type="password" name="token" autocomplete="off" data-so-secret data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other" aria-label="Token from @BotFather">`) }),
|
|
340
|
+
note(html `In Telegram, send <code class="k-mono">/status</code>, <code class="k-mono">/task <id></code> or <code class="k-mono">/help</code>; these use no AI model.`),
|
|
341
|
+
]) });
|
|
342
|
+
}
|
|
343
|
+
function phoneSection(phone) {
|
|
344
|
+
return settingsSection({ id: "phone", title: "Phone access", lead: "Open Toolroll itself on your phone, over your own private network.",
|
|
345
|
+
body: html `<p class="k-note" data-phone-tailnet>${phone.tailnet === null ? html `Install <a class="k-link" href="https://tailscale.com/download">Tailscale</a> on this computer and your phone, then reload this page for the address.`
|
|
346
|
+
: phone.tailnet.restart === null ? html `On your phone, open <code class="k-mono">${phone.tailnet.address}</code> and sign in.`
|
|
347
|
+
: html `Start Toolroll with <code class="k-mono">${phone.tailnet.restart}</code>, then on your phone open <code class="k-mono">${phone.tailnet.address}</code> and sign in.`}</p>` });
|
|
348
|
+
}
|
|
349
|
+
export function alertsGroup(facts, flags) {
|
|
350
|
+
const legacy = facts.messaging?.legacyWarning ? note(html `Legacy webhooks are deprecated. Connect <a class="k-link" href="/settings/slack">Slack</a> or <a class="k-link" href="/settings/discord">Discord</a> instead.`) : "";
|
|
351
|
+
return settingsShell({
|
|
352
|
+
group: "alerts", title: "Chat apps and alerts", lead: "What reaches you, where, and how.", flags,
|
|
353
|
+
body: joinHtml([answered(facts), legacy, chatAppsSection(facts), reachSection(facts), serviceSection(facts), digestSection(facts), pushSection(facts),
|
|
354
|
+
facts.phone === null ? "" : phoneSection(facts.phone), facts.email === null ? "" : emailSection(facts.email), facts.approver && facts.browser && facts.installation ? telegramTokenSection(facts) : ""]),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
export function approvalGroup(view) {
|
|
358
|
+
const facts = view.facts;
|
|
359
|
+
const defaults = facts.permission === null && facts.quality === null ? "" : settingsSection({
|
|
360
|
+
id: "defaults", title: "Defaults for new tasks", lead: "Approved tasks keep their own setting.",
|
|
361
|
+
body: joinHtml([
|
|
362
|
+
facts.permission === null ? "" : facts.permission.canManage
|
|
363
|
+
? kitSectionForm({ proof: "permission-default", action: "/settings/permission-default", id: "permission-form", gate: true, proofWhen: ["permission-mode", "bypassPermissions"],
|
|
364
|
+
said: facts.permission.changed, confirm: "Start new tasks with Full access",
|
|
365
|
+
body: formRow("Unattended permissions", "Full access lets builds run any command without asking. Choosing it asks for your password.", radios({ name: "permission-mode", legend: "Unattended permissions", current: facts.permission.mode, items: [
|
|
366
|
+
{ value: "auto", label: "Auto", text: "Asks before risky actions." },
|
|
367
|
+
{ value: "bypassPermissions", label: "Full access", text: "Never asks and can change files anywhere on this computer. Trusted repositories only." }
|
|
368
|
+
] }), "wide") })
|
|
369
|
+
: formRow("Unattended permissions", "An approver can change this.", html `<span>${facts.permission.mode === "bypassPermissions" ? "Full access" : "Auto"}</span>`, "inline"),
|
|
370
|
+
facts.quality === null ? "" : facts.quality.canManage
|
|
371
|
+
? kitSectionForm({ proof: "quality-default", action: "/settings/quality-default", id: "quality-form", gate: true, said: facts.quality.changed,
|
|
372
|
+
body: formRow("Quality", "Publishing and deploying still need their own approval.", radios({ name: "quality-mode", legend: "Quality", current: facts.quality.mode, items: [
|
|
373
|
+
{ value: "default", label: "Default", text: "Everyday agents and the repository check." },
|
|
374
|
+
{ value: "strict", label: "Strict / release", text: "Strongest agents. Release approval stays separate." }
|
|
375
|
+
] }), "wide") })
|
|
376
|
+
: formRow("Quality", "An approver can change this.", html `<span>${qualityModeTitle(facts.quality.mode)}</span>`, "inline"),
|
|
377
|
+
]),
|
|
378
|
+
});
|
|
379
|
+
const picker = view.projects.length > 1
|
|
380
|
+
? html `<form method="get" action="/settings/approval" class="k-acts">${select({ name: "repo", label: "Project", current: view.chosen, items: view.projects.map(one => [one.repo, one.name]) })}${btn("Show", { sm: true, type: "submit" })}</form>`
|
|
381
|
+
: html ``;
|
|
382
|
+
const rules = view.chosen === "" ? settingsSection({ id: "rules", title: "Approval rules", body: note("Add a project to set its approval rules.") })
|
|
383
|
+
: joinHtml([picker, view.rules]);
|
|
384
|
+
const links = settingsLinks("In this group", [
|
|
385
|
+
settingsLink({ href: "/mode", title: "Operating mode", status: view.mode }),
|
|
386
|
+
settingsLink({ href: "/settings/policy", title: "Policy", status: "Limits no task can go past, whatever its mode or approver" }),
|
|
387
|
+
settingsLink({ href: "/caps", title: "Requirements", status: "What builders are missing for waiting work" }),
|
|
388
|
+
settingsLink({ href: "/settings/projects", title: "Network, writes and sandbox", status: "Set for each project" }),
|
|
389
|
+
]);
|
|
390
|
+
return settingsShell({
|
|
391
|
+
group: "approval", title: "Approvals and safety", lead: "How much runs without asking, and who approves the rest.", flags: view.flags,
|
|
392
|
+
body: joinHtml([answered(facts), defaults, rules,
|
|
393
|
+
note(html `<b>Which setting wins:</b> protected work and policy limits always apply; then a project’s operating mode; then these defaults.`), links]),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// ---- Projects, People and sign-in, System -----------------------------------------------------------------------
|
|
397
|
+
export function projectsGroup(facts, flags) {
|
|
398
|
+
const rows = facts.projects.map(one => settingsLink({ href: `/settings/project?repo=${encodeURIComponent(one.repo)}`, title: one.name, status: html `<span class="k-mono">${one.repo}</span>` }));
|
|
399
|
+
const per = facts.projects.length === 0 ? "" : settingsLinks("For each project", [
|
|
400
|
+
settingsLink({ href: "/settings/checks", title: "Checks", status: "What runs after each build" }),
|
|
401
|
+
settingsLink({ href: "/settings/pull-requests", title: "Pull requests", status: "How finished work reaches GitHub" }),
|
|
402
|
+
settingsLink({ href: "/settings/tools", title: "Tools", status: "What builds can use" }),
|
|
403
|
+
settingsLink({ href: "/control", title: "Builder setup", status: "Agent, model and preparation command" }),
|
|
404
|
+
]);
|
|
405
|
+
return settingsShell({
|
|
406
|
+
group: "projects", title: "Projects", lead: "Each project has its own network, writes, checks, pull requests and tools.", flags,
|
|
407
|
+
body: joinHtml([answered(facts),
|
|
408
|
+
facts.projects.length === 0
|
|
409
|
+
? settingsSection({ id: "projects", title: "Projects", body: note(html `No projects yet. <a class="k-link" href="/projects">Add one on Projects</a>.`) })
|
|
410
|
+
: settingsLinks("Projects", rows),
|
|
411
|
+
per]),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
export function accessGroup(facts, flags) {
|
|
415
|
+
const people = facts.people;
|
|
416
|
+
const links = [
|
|
417
|
+
settingsLink({ href: "/people", title: "People", status: people === null ? "Your own access" : [accessWords({ ...facts, passwordless: null }), people.invites === 0 ? "no open invites" : plural(people.invites, "open invite", "open invites")].join(" · ") }),
|
|
418
|
+
settingsLink({ href: "/settings/sessions", title: "Sessions and tokens", status: `Signed in on ${plural(facts.access.sessions, "device", "devices")} · ${plural(facts.access.tokens, "API token", "API tokens")}` }),
|
|
419
|
+
...(facts.operator ? [
|
|
420
|
+
settingsLink({ href: "/settings/sign-in", title: "Sign-in with a provider", status: facts.sso === null ? "Not set up: everyone uses a password" : `People sign in with ${facts.sso}` }),
|
|
421
|
+
settingsLink({ href: "/settings/passwordless", title: "Passwordless", status: facts.passwordless ? "On: signing in is the only password" : "Off: approvals ask for your password" }),
|
|
422
|
+
] : []),
|
|
423
|
+
];
|
|
424
|
+
return settingsShell({ group: "access", title: "People and sign-in", lead: "Who can get in, how they sign in, and the ways in they hold.", flags,
|
|
425
|
+
body: joinHtml([answered(facts), settingsLinks(null, links, { label: "People and sign-in" })]) });
|
|
426
|
+
}
|
|
427
|
+
function updatesSection(updates) {
|
|
428
|
+
const latest = updates.latest;
|
|
429
|
+
const newer = latest !== null && latest.newer ? latest : null;
|
|
430
|
+
const state = newer ? html `${newer.version} is available` : !updates.check.on ? "Checks are off" : latest === null ? "Not checked yet" : "Up to date";
|
|
431
|
+
return settingsSection({ id: "updates", title: "Updates", state: html ` · ${state}`,
|
|
432
|
+
body: joinHtml([
|
|
433
|
+
formRow("This version", newer?.security ? "The newer version has security fixes." : null, html `<span class="k-mono">${updates.current}</span>`, "inline"),
|
|
434
|
+
newer === null ? "" : joinHtml([note("Run this on this computer, then restart Toolroll:"), codeLine(updates.updateCommand),
|
|
435
|
+
html `<div class="k-acts">${btn("Release notes", { sm: true, variant: "ghost", href: newer.url })}</div>`,
|
|
436
|
+
newer.notes === "" ? "" : disclose(`What’s new in ${newer.version}`, html `<pre class="k-scode">${newer.notes}</pre>`)]),
|
|
437
|
+
updates.check.canManage && !updates.check.byEnv
|
|
438
|
+
? postForm("/settings/updates/checks", formRow("Check once a day", "One anonymous request to npm and GitHub. Nothing about you is sent.", btn(updates.check.on ? "Turn off" : "Turn on", { sm: true, type: "submit" }), "inline"), { hidden: { check: updates.check.on ? "off" : "on" } })
|
|
439
|
+
: updates.check.byEnv ? note("Daily checks are off by TOOLROLL_NO_UPDATE_CHECK.") : "",
|
|
440
|
+
...updates.workers.map(one => formRow(one.name, one.older ? "Older than this computer" : null, html `<span class="k-mono">${one.version ?? "Version not reported"}</span>`, "inline")),
|
|
441
|
+
]) });
|
|
442
|
+
}
|
|
443
|
+
export function systemGroup(facts, flags) {
|
|
444
|
+
const links = facts.operator ? settingsLinks("In this group", [
|
|
445
|
+
settingsLink({ href: "/settings/updates", title: "Updates", status: facts.updates === null ? "Toolroll’s own version" : `Toolroll ${facts.updates.current}` }),
|
|
446
|
+
settingsLink({ href: "/settings/backups", title: "Backups", status: facts.backup?.words ?? "When and where the database is copied", needs: facts.backup?.failed ? 1 : 0 }),
|
|
447
|
+
settingsLink({ href: "/settings/storage", title: "Storage", status: "Checkouts on this computer, and cleaning them up" }),
|
|
448
|
+
settingsLink({ href: "/settings/retention", title: "Retention", status: "How long run logs and chats are kept" }),
|
|
449
|
+
settingsLink({ href: "/settings/monitoring", title: "Monitoring", status: "Send the audit stream and traces to your own systems" }),
|
|
450
|
+
settingsLink({ href: "/settings/data", title: "Data export", status: "Everything except secrets, as one download" }),
|
|
451
|
+
settingsLink({ href: "/settings/integrations", title: "Integrations", status: "Which connected services work" }),
|
|
452
|
+
settingsLink({ href: "/fleet", title: "Builders", status: "Every computer that builds, on Agents" }),
|
|
453
|
+
]) : settingsLinks("In this group", [
|
|
454
|
+
settingsLink({ href: "/settings/integrations", title: "Integrations", status: "Which connected services work" }),
|
|
455
|
+
settingsLink({ href: "/fleet", title: "Builders", status: "Every computer that builds" }),
|
|
456
|
+
]);
|
|
457
|
+
return settingsShell({ group: "system", title: "System", lead: "This Toolroll’s version, backups, storage and what leaves this computer.", flags,
|
|
458
|
+
body: joinHtml([answered(facts), facts.updates === null ? "" : updatesSection(facts.updates), links]) });
|
|
459
|
+
}
|
|
460
|
+
/** One group page by its slug. */
|
|
461
|
+
export function settingsGroupPage(group, facts) {
|
|
462
|
+
const flags = flagsOf(needsByGroup(settingsNeeds(facts)));
|
|
463
|
+
switch (group) {
|
|
464
|
+
case "hub": return settingsHub(facts);
|
|
465
|
+
case "general": return generalGroup(facts, flags);
|
|
466
|
+
case "agents": return agentsGroup(facts, flags);
|
|
467
|
+
case "alerts": return alertsGroup(facts, flags);
|
|
468
|
+
case "projects": return projectsGroup(facts, flags);
|
|
469
|
+
case "access": return accessGroup(facts, flags);
|
|
470
|
+
case "system": return systemGroup(facts, flags);
|
|
471
|
+
case "approval": return settingsHub(facts);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
export const groupTitle = (group) => group === "hub" ? "Settings" : groupLabel(group);
|
|
475
|
+
export { groupHref };
|
|
476
|
+
/** A settings sub-page in the frame: the group's breadcrumb, the page's title, its old body inside. */
|
|
477
|
+
export function settingsSubPage(group, title, body, options = {}) {
|
|
478
|
+
return settingsShell({ group, title, sub: true, body, ...(options.lead === undefined ? {} : { lead: options.lead }), ...(options.flags === undefined ? {} : { flags: options.flags }) });
|
|
479
|
+
}
|
|
480
|
+
/** /mode: the mode a project runs under now, and (for an approver) the choices to sign a new one. Reading the terms is
|
|
481
|
+
* the next page (/mode/confirm), where the password signs exactly what it shows. */
|
|
482
|
+
export function modePageHtml(view) {
|
|
483
|
+
const now = settingsSection({ id: "mode-now", title: "Now",
|
|
484
|
+
body: view.live === null
|
|
485
|
+
? formRow("Locked", "No mode is signed: every plan and merge waits for a person. That is the default.", html `<span class="k-muted">No mode signed</span>`, "inline")
|
|
486
|
+
: joinHtml([
|
|
487
|
+
settingsRecord({ title: html `${view.name} ${chip(view.live.name === "hands-off" ? "Hands-off" : "Standard", "live")}`, sub: `Signed by ${view.live.signedBy} · ends ${view.live.until}`,
|
|
488
|
+
action: view.approver ? postForm("/mode/revoke", btn("End this mode now", { sm: true, type: "submit" })) : "" }),
|
|
489
|
+
html `<ul class="k-note">${view.live.words.map(words => html `<li>${words}</li>`)}</ul>`,
|
|
490
|
+
view.approver ? note("Ending it is one click: every act it covered goes back to asking a person.") : "",
|
|
491
|
+
]) });
|
|
492
|
+
const sign = !view.approver ? note("An approver of this project signs its operating mode.") : settingsSection({
|
|
493
|
+
id: "mode-sign", title: view.live === null ? `Sign a mode for ${view.name}` : `Replace it with a new mode for ${view.name}`,
|
|
494
|
+
lead: "Choose what may run without asking. The next page shows every term in words, and your password signs exactly those.",
|
|
495
|
+
body: postForm("/mode/confirm", joinHtml([
|
|
496
|
+
formRow("Preset", null, radios({ name: "name", legend: "Preset", current: "standard", items: [
|
|
497
|
+
{ value: "standard", label: "Standard", text: "Approvals stay with you." },
|
|
498
|
+
{ value: "hands-off", label: "Hands-off", text: "Tasks you file approve themselves and run with full permissions." }
|
|
499
|
+
] }), "wide"),
|
|
500
|
+
formRow("Lasts", `Days, from now: 1 to ${view.maxDays}.`, input({ id: "mode-days", name: "days", type: "number", value: "1", attrs: { min: 1, max: view.maxDays, "aria-label": "Lasts, in days" } })),
|
|
501
|
+
formRow("Merges", view.hasMergeGrant ? null : "Merging by itself needs a merge grant on the project first.", select({ name: "publication", label: "Merges", current: "notify",
|
|
502
|
+
items: [["notify", "Wait for me"], ...(view.hasMergeGrant ? [["automerge", "Merge when CI passes on the exact commit"]] : [])] })),
|
|
503
|
+
formRow("Tasks I file", null, select({ name: "auto-approve", label: "Tasks I file", current: "",
|
|
504
|
+
items: [["", "The preset’s choice"], ["1", "Approve them as I file them"], ["0", "Wait for their own approval"]] })),
|
|
505
|
+
formRow("Plans", "Needs tasks to approve as you file them. Changed scope and open questions still wait for you.", select({ name: "plan-auto", label: "Plans", current: "0",
|
|
506
|
+
items: [["0", "Wait for a person"], ["1", "Approve plans that keep what I filed"]] })),
|
|
507
|
+
formRow("Approving from a chat app", "Plans that widen permissions, go past the attempt limit or touch protected paths still open Toolroll.", select({ name: "chat-approve", label: "Approving from a chat app", current: "",
|
|
508
|
+
items: [["", "Here, with my password"], ["1", "In my paired chat app, two taps"]] })),
|
|
509
|
+
html `<div class="k-acts">${btn("Review the terms", { variant: "primary", type: "submit" })}</div>`,
|
|
510
|
+
]))
|
|
511
|
+
});
|
|
512
|
+
const answer = view.problem ? notice(sentence(view.problem)) : view.said ? infoLine(sentence(view.said)) : "";
|
|
513
|
+
return settingsSubPage("approval", "Operating mode", joinHtml([answer, now, sign]), { lead: "A signed, time-limited mode that lets more run without asking. It ends on its own. Scope changes, agent questions and requested revisions still wait for you." });
|
|
514
|
+
}
|
|
515
|
+
/** /mode/confirm: every resolved term in words; the password signs exactly this digest (a changed form is refused). */
|
|
516
|
+
export function modeCeremonyHtml(view) {
|
|
517
|
+
return settingsSubPage("approval", `Sign the ${view.name} mode`, joinHtml([
|
|
518
|
+
settingsSection({ id: "mode-terms", title: `What you sign for ${view.project}`,
|
|
519
|
+
body: html `<ul class="k-ssec-lead">${view.words.map(words => html `<li>${words}</li>`)}</ul>` }),
|
|
520
|
+
kitSectionForm({ proof: view.chatApprove ? "mode-sign-chat-approve" : "mode-sign", action: "/mode/sign", id: "mode-sign-form", native: true, hidden: view.hidden,
|
|
521
|
+
save: { label: "Sign it" }, confirm: `Sign the ${view.name} mode for ${view.project}`, body: html `` }),
|
|
522
|
+
note(html `<a class="k-link" href="/mode">Back, and sign nothing</a>`),
|
|
523
|
+
]));
|
|
524
|
+
}
|
|
525
|
+
const roleWords = (approver) => approver ? "Can manage and approve" : "Can view";
|
|
526
|
+
function accessFields(id, projects, selected) {
|
|
527
|
+
return joinHtml([
|
|
528
|
+
formRow("Projects", "With all projects, someone who can manage and approve also manages this Toolroll.", select({ name: "access", label: "Project access", current: selected === null ? "all" : "selected",
|
|
529
|
+
items: [["selected", "Only the projects ticked below"], ["all", "All projects"]] })),
|
|
530
|
+
projects.length === 0 ? note("Add a project before giving access to some projects only.")
|
|
531
|
+
: html `<fieldset class="k-sopts"><legend class="k-sr">Projects</legend>${projects.map((one, at) => html `<label class="k-sopt" for="${id}-p${at}"><input type="checkbox" id="${id}-p${at}" name="projects" value="${one.repo}"${selected?.includes(one.repo) ? html ` checked` : ""}><span><b>${one.name}</b></span></label>`)}</fieldset>`,
|
|
532
|
+
]);
|
|
533
|
+
}
|
|
534
|
+
/** People: who can get in, what each can do and where, open invites, and inviting someone. Inviting and widening
|
|
535
|
+
* access always ask for the password typed now; removing someone or cancelling an invite is a step-up. */
|
|
536
|
+
export function peopleHtml(view) {
|
|
537
|
+
const answer = view.problem ? notice(sentence(view.problem)) : view.said ? infoLine(sentence(view.said)) : "";
|
|
538
|
+
const people = settingsSection({ id: "people", title: "People", state: html ` <span class="k-count">${view.people.filter(one => one.revoked === null).length}</span>`,
|
|
539
|
+
body: joinHtml(view.people.map((one, index) => {
|
|
540
|
+
const slug = `person-${index}`;
|
|
541
|
+
const edit = !view.operator || one.revoked !== null ? "" : joinHtml([
|
|
542
|
+
kitSectionForm({ proof: "people-projects", action: "/people/projects", id: `${slug}-access`, gate: true, hidden: { name: one.name }, save: { label: "Save access" },
|
|
543
|
+
confirm: `Change ${one.name}’s projects`, body: joinHtml([accessFields(`${slug}-access`, view.projects, one.projects), note("Changing access signs this person out and ends their sessions and modes. Work they approved stays recorded.")]) }),
|
|
544
|
+
one.you ? "" : kitSectionForm({ proof: "people-close", action: "/people/revoke", id: `${slug}-remove`, hidden: { name: one.name }, save: { label: `Remove ${one.name}`, variant: "danger" },
|
|
545
|
+
confirm: `Remove ${one.name}’s sign-in`, body: note("Ends their access and everything it signed. History stays.") }),
|
|
546
|
+
]);
|
|
547
|
+
const recent = one.acts.length === 0 ? note("No recorded acts yet.") : note(`Recent: ${one.acts.slice(0, 3).join(" · ")}`);
|
|
548
|
+
return settingsRecord({
|
|
549
|
+
title: html `${one.name}${one.you ? html ` <span class="k-muted">(you)</span>` : ""}${one.revoked === null ? "" : html ` ${chip("Removed", "")}`}`,
|
|
550
|
+
sub: one.revoked ?? `${roleWords(one.approver)} · ${one.projectNames}`, when: one.seen,
|
|
551
|
+
action: btn("Activity", { sm: true, variant: "ghost", href: one.activity }),
|
|
552
|
+
more: edit === "" ? (view.operator ? recent : "") : disclose(`Edit ${one.name}`, joinHtml([recent, edit])),
|
|
553
|
+
attrs: { "data-person": one.name },
|
|
554
|
+
});
|
|
555
|
+
})) });
|
|
556
|
+
const invites = !view.operator ? "" : settingsSection({ id: "invites", title: "Invites",
|
|
557
|
+
body: joinHtml([
|
|
558
|
+
view.invites.length === 0 ? note("No open invites.") : joinHtml(view.invites.map(one => settingsRecord({
|
|
559
|
+
title: roleWords(one.role === "approver"), sub: `${one.projects} · from ${one.from}${one.attempts > 0 ? ` · ${plural(one.attempts, "failed attempt", "failed attempts")}` : ""}`, when: `Expires ${one.expires}`,
|
|
560
|
+
action: kitSectionForm({ proof: "people-close", action: "/people/invite-revoke", id: `invite-${one.id}-cancel`, hidden: { id: one.id }, save: { label: "Cancel invite", variant: "outline" }, confirm: "Cancel this invite", body: html `` }),
|
|
561
|
+
}))),
|
|
562
|
+
disclose("Invite someone", kitSectionForm({ proof: "people-invite", action: "/people/invite", id: "invite-form", native: true, save: { label: "Create invite link" }, confirm: "Invite someone",
|
|
563
|
+
body: joinHtml([
|
|
564
|
+
formRow("They can", null, radios({ name: "role", legend: "They can", current: "viewer", items: [
|
|
565
|
+
{ value: "viewer", label: "Can view", text: "Read work in their projects." },
|
|
566
|
+
{ value: "approver", label: "Can manage and approve", text: "Create, manage and approve work in their projects." }
|
|
567
|
+
] }), "wide"),
|
|
568
|
+
accessFields("invite", view.projects, view.openProject === null ? [] : [view.openProject]),
|
|
569
|
+
note("The link works once, for one person, and expires in 72 hours. An invite creates a new way in, so it always asks for your password."),
|
|
570
|
+
]) }), view.invites.length === 0),
|
|
571
|
+
]) });
|
|
572
|
+
const filers = view.filers.length === 0 ? "" : settingsSection({ id: "agent-filers", title: "Agent filers", lead: "Agents that can propose tasks. You approve what runs.",
|
|
573
|
+
body: joinHtml([...view.filers.map(one => settingsRecord({ title: html `<span class="k-mono">${one.name}</span>`, sub: one.words })),
|
|
574
|
+
note(html `To revoke one, run <code class="k-mono">toolroll coordinator revoke <cid> --as you</code> on this computer.`)]) });
|
|
575
|
+
return settingsSubPage("access", "People", joinHtml([answer, people, invites, filers]), { lead: "Who can see and approve work in this Toolroll." });
|
|
576
|
+
}
|
|
577
|
+
/** People for someone limited to some projects: their own access, in words. */
|
|
578
|
+
export function ownAccessHtml(view) {
|
|
579
|
+
return settingsSubPage("access", "Your access", joinHtml([
|
|
580
|
+
settingsSection({ id: "your-access", title: `${view.name} · ${roleWords(view.approver)}`, lead: view.approver ? "You can create, manage and approve work in these projects." : "You can read work in these projects.",
|
|
581
|
+
body: joinHtml([...view.projects.map(name => formRow(name, null, html ``, "inline")), note("An instance operator manages invites and project access."),
|
|
582
|
+
html `<div class="k-acts">${btn("Your activity", { sm: true, href: view.activity })}</div>`]) }),
|
|
583
|
+
]));
|
|
584
|
+
}
|
|
585
|
+
/** The invite link, once, on a page with no script: selectable text, what it lets them do, and where to cancel it. */
|
|
586
|
+
export function inviteLinkHtml(view) {
|
|
587
|
+
return settingsSubPage("access", "Invite link", joinHtml([
|
|
588
|
+
settingsSection({ id: "invite-link", title: "Shown once, so copy it now",
|
|
589
|
+
body: joinHtml([html `<p class="k-secret-val">${view.link}</p>`,
|
|
590
|
+
note(`Send it to one person. It works once, lets them ${view.approver ? "manage and approve work" : "read work"} in ${view.projects}, and expires ${view.expires}. Cancel it any time on People.`),
|
|
591
|
+
html `<div class="k-acts">${btn("Back to People", { sm: true, href: "/people" })}</div>`]) }),
|
|
592
|
+
]));
|
|
593
|
+
}
|
|
594
|
+
// ---- Passwordless (/settings/passwordless) ----------------------------------------------------------------------
|
|
595
|
+
/** The switch and the trade in plain words. Turning it on is a way in: it always asks for the password typed now.
|
|
596
|
+
* Turning it off is one click. */
|
|
597
|
+
export function passwordlessHtml(view) {
|
|
598
|
+
const answer = view.problem ? notice(sentence(view.problem)) : view.said ? infoLine(sentence(view.said)) : "";
|
|
599
|
+
const control = view.on
|
|
600
|
+
? postForm("/settings/passwordless", html `<div class="k-acts">${btn("Turn Passwordless off", { type: "submit" })}</div>`, { hidden: { action: "off" } })
|
|
601
|
+
: kitSectionForm({ proof: "passwordless-on", action: "/settings/passwordless", id: "passwordless-form", hidden: { action: "on" }, save: { label: "Turn Passwordless on" },
|
|
602
|
+
confirm: "Turn Passwordless on", body: html `` });
|
|
603
|
+
return settingsSubPage("access", "Passwordless", joinHtml([
|
|
604
|
+
answer,
|
|
605
|
+
settingsSection({ id: "passwordless", title: `Passwordless is ${view.on ? "on" : "off"}`, state: view.since === null ? "" : ` · ${view.since}`,
|
|
606
|
+
lead: "When it’s on, signing in is the only password: approvals, settings, budgets, signing a mode and starting a terminal no longer ask again, in the browser, on the phone, and in this computer’s toolroll command (through its remembered login).",
|
|
607
|
+
body: joinHtml([note(view.trade), control]) }),
|
|
608
|
+
]), { lead: "Sign in once; most approvals then need no password." });
|
|
609
|
+
}
|
|
610
|
+
/** Which project a per-project settings page shows: a GET form (an address, not a save). */
|
|
611
|
+
export function projectPicker(action, projects, chosen) {
|
|
612
|
+
if (projects.length < 2)
|
|
613
|
+
return html ``;
|
|
614
|
+
return html `<form method="get" action="${action}" class="k-acts k-picker">${select({ name: "repo", label: "Project", current: chosen, items: projects.map(one => [one.repo, one.name]) })}${btn("Show", { sm: true, type: "submit" })}</form>`;
|
|
615
|
+
}
|