toolroll 0.9.83 → 0.9.85
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 +69 -0
- package/README.md +94 -1192
- package/dist/agent-fence.d.ts +3 -3
- package/dist/agent-fence.js +3 -3
- package/dist/agent-onboard.d.ts +2 -0
- package/dist/agent-onboard.js +3 -0
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +12 -0
- package/dist/chat-channel.d.ts +10 -1
- package/dist/chat-channel.js +35 -5
- package/dist/chat-decide.d.ts +16 -4
- package/dist/chat-decide.js +239 -31
- package/dist/chat-messages.d.ts +5 -0
- package/dist/chat-messages.js +1 -1
- package/dist/chat-quiet.js +3 -2
- package/dist/chat-review.d.ts +4 -0
- package/dist/chat-review.js +13 -4
- package/dist/chat-terms.d.ts +9 -0
- package/dist/chat-terms.js +46 -0
- package/dist/contracts/console-api.d.ts +4 -2
- package/dist/contracts/console-api.js +7 -2
- package/dist/contracts/lead-tools.d.ts +30 -0
- package/dist/contracts/lead-tools.js +3 -0
- package/dist/contracts/youiyoux-request.d.ts +248 -0
- package/dist/contracts/youiyoux-request.js +152 -0
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/flow-cadence.d.ts +8 -2
- package/dist/flow-cadence.js +16 -3
- package/dist/flow-card-task.d.ts +9 -0
- package/dist/flow-card-task.js +15 -6
- package/dist/folder-look.d.ts +3 -0
- package/dist/folder-look.js +8 -4
- package/dist/guides.js +4 -3
- package/dist/integrations-ui.js +9 -4
- package/dist/integrations.d.ts +9 -1
- package/dist/integrations.js +43 -2
- package/dist/landed-cleanup.d.ts +4 -0
- package/dist/landed-cleanup.js +3 -2
- package/dist/lead-capabilities.js +2 -1
- package/dist/lead-cli.js +3 -1
- package/dist/lead-tools.d.ts +2 -0
- package/dist/lead-tools.js +30 -4
- package/dist/lead.d.ts +2 -0
- package/dist/lead.js +3 -3
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +22 -2
- package/dist/operate.js +107 -41
- package/dist/project-activity.d.ts +20 -0
- package/dist/project-activity.js +23 -0
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-cli.d.ts +1 -0
- package/dist/project-cli.js +36 -19
- package/dist/project-identity.d.ts +9 -2
- package/dist/project-identity.js +80 -33
- package/dist/project-knowledge.d.ts +9 -9
- package/dist/project-outcomes.d.ts +119 -0
- package/dist/project-outcomes.js +79 -0
- package/dist/result-actions.d.ts +4 -0
- package/dist/result-actions.js +41 -9
- package/dist/result-review.d.ts +9 -0
- package/dist/result-review.js +45 -5
- package/dist/serve.js +60 -13
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +17 -2
- package/dist/server/flows.js +4 -0
- package/dist/server/http.d.ts +1 -0
- package/dist/server/http.js +6 -0
- package/dist/server/pages.js +53 -7
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-chat.js +3 -2
- package/dist/server/render-pages.d.ts +12 -2
- package/dist/server/render-pages.js +36 -16
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +20 -7
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.d.ts +1 -0
- package/dist/server/render-tasks.js +22 -19
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +9 -1
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +216 -70
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/task-views.js +6 -0
- package/dist/server/tasks.js +54 -8
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/store.d.ts +46 -3
- package/dist/store.js +165 -6
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/task-source.d.ts +13 -1
- package/dist/task-source.js +17 -1
- package/dist/telegram-lead.js +7 -5
- package/dist/telegram.js +1 -1
- package/dist/toolroll-update.js +2 -2
- package/dist/webhooks.d.ts +6 -0
- package/dist/webhooks.js +25 -0
- package/dist/work-index.d.ts +4 -0
- package/dist/work-index.js +32 -0
- package/dist/youiyoux-intake.d.ts +234 -0
- package/dist/youiyoux-intake.js +908 -0
- package/package.json +11 -8
package/dist/server/tasks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseFeedbackImage, sameFeedbackImage } from "../result-review.js";
|
|
2
|
+
import { dropFeedbackImage, releaseAuthorityOfTask, revokeRelease, runLiveCommands } from "../store.js";
|
|
2
3
|
import { steerCapabilityOf } from "../steer-capabilities.js";
|
|
3
4
|
import { canReveal, localCheckoutOf, revealInFinder } from "../checkout-proof.js";
|
|
4
5
|
/** A form's way back to a flow card ("flow:<flow>:<card>"), read strictly: numbers only, never a path. */
|
|
@@ -27,7 +28,8 @@ import { codingWorkspaceHtml, codingWorkspaceScript } from '../coding-ui.js';
|
|
|
27
28
|
import { CodingActionError } from '../coding-workspace.js';
|
|
28
29
|
import { checkResponse, CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
29
30
|
import { hasForbiddenControls, validateNote } from "../decision.js";
|
|
30
|
-
import { diagnoseTaskDispatch } from "../dispatch.js";
|
|
31
|
+
import { builderReadyFor, diagnoseTaskDispatch, firstRunBuilderRepo } from "../dispatch.js";
|
|
32
|
+
import { withBuilderStep } from "../first-run.js";
|
|
31
33
|
import { evidencePack, evidencePackHtml } from "../evidence-pack.js";
|
|
32
34
|
import { readVerifiedArtifact, readVerifiedReport, writeEvidenceFile } from "../evidence.js";
|
|
33
35
|
import { computeGaps } from "../gaps.js";
|
|
@@ -47,7 +49,7 @@ import { fileTaskProposal } from "../proposal.js";
|
|
|
47
49
|
import { isProviderId, PROVIDER_IDS, validateSpec } from "../provider.js";
|
|
48
50
|
import { completeAndOpenPullRequest, mergeAsPerson } from '../pull-request-flow.js';
|
|
49
51
|
import { isQualityMode } from "../quality.js";
|
|
50
|
-
import { createResultRevision, requestResultChanges } from "../result-actions.js";
|
|
52
|
+
import { createResultRevision, requestResultChanges, feedbackImageProblem } from "../result-actions.js";
|
|
51
53
|
import { structuredHandoffView, terminalDiffView } from "../result-evidence-readers.js";
|
|
52
54
|
import { fileAddTestsTask, requestFollowUpChecks } from '../result-follow-ups.js';
|
|
53
55
|
import { commentSourceKey, isRevisionFeedback, parseResultTab, resultReturnTarget } from "../result-review.js";
|
|
@@ -175,7 +177,8 @@ export function createTasksHandlers(runtime) {
|
|
|
175
177
|
})),
|
|
176
178
|
cancelledBlockers: cancelled,
|
|
177
179
|
gaps: project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10),
|
|
178
|
-
|
|
180
|
+
// The builder step speaks for the same project as Chat's (firstRunBuilderRepo), as dispatch decides.
|
|
181
|
+
wizard: withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, managedRepos()), now)),
|
|
179
182
|
worker: (() => {
|
|
180
183
|
// The one fact the inbox must never hide (install review): with
|
|
181
184
|
// no worker answering, nothing here will ever build, and every
|
|
@@ -1020,6 +1023,8 @@ export function createTasksHandlers(runtime) {
|
|
|
1020
1023
|
}
|
|
1021
1024
|
let repo = effective;
|
|
1022
1025
|
let admitted = unscopedMode ? null : admissionList() ?? [];
|
|
1026
|
+
// What this filing is decided on: re-checked inside the write below, after every await (project-identity.ts).
|
|
1027
|
+
const decidedOn = identities.generation();
|
|
1023
1028
|
const rootMode = !unscopedMode && ceiling.roots.length > 0;
|
|
1024
1029
|
if (rootMode && effective !== "") {
|
|
1025
1030
|
// Resolved off the console's thread (project-identity.ts), then proved against the ceiling as that identity.
|
|
@@ -1052,6 +1057,10 @@ export function createTasksHandlers(runtime) {
|
|
|
1052
1057
|
const after = (body.get("after") ?? "").trim();
|
|
1053
1058
|
let chainProblem = null;
|
|
1054
1059
|
const made = store.transact(() => {
|
|
1060
|
+
// A project folder's identity changed while this request waited (a root or link now leads elsewhere): what was
|
|
1061
|
+
// proved above may no longer hold, so nothing is filed on it.
|
|
1062
|
+
if (identities.generation() !== decidedOn || (rootMode && repo !== "" && !visible(repo)))
|
|
1063
|
+
return null;
|
|
1055
1064
|
const filed = fileTaskProposal(store, {
|
|
1056
1065
|
...(id === "" ? {} : { id }),
|
|
1057
1066
|
title,
|
|
@@ -1083,6 +1092,8 @@ export function createTasksHandlers(runtime) {
|
|
|
1083
1092
|
applyModeToNewFiling(store, filed.id, who.name, now);
|
|
1084
1093
|
return filed;
|
|
1085
1094
|
});
|
|
1095
|
+
if (made === null)
|
|
1096
|
+
return newTaskScreen(ctx, 409, "a project folder changed while this was being filed — check the project and send it again", draft);
|
|
1086
1097
|
if (!made.ok) {
|
|
1087
1098
|
return newTaskScreen(ctx, made.reason === "backlog-full" ? 429 : 400, made.message, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent });
|
|
1088
1099
|
}
|
|
@@ -1392,15 +1403,40 @@ export function createTasksHandlers(runtime) {
|
|
|
1392
1403
|
const viewParam = returnTo.startsWith("/t/") ? "section" : "tab";
|
|
1393
1404
|
const back = selectedTab === "summary" ? returnTo
|
|
1394
1405
|
: `${returnTo.replace(/§ion=(?:summary|changes|checks)$/, "")}${returnTo.includes("?") ? "&" : "?"}${viewParam}=${selectedTab}`;
|
|
1406
|
+
if (body.get("intent") === "drop-image") {
|
|
1407
|
+
// A saved note whose screenshot is gone or changed would block every revision that seals it. Its words stay:
|
|
1408
|
+
// the note is superseded by an exact imageless copy, and the original remains in history.
|
|
1409
|
+
// Who: a signed-in browser approver admitted to this run's project (checked above) — the same authority that
|
|
1410
|
+
// seals any saved note into a revision. What: only a screenshot that is unusable right now, re-proved inside
|
|
1411
|
+
// dropFeedbackImage's transaction, and recorded in the ledger under this person.
|
|
1412
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
1413
|
+
return refuse(response, who, 403, "Only a signed-in approver can change saved feedback.", back);
|
|
1414
|
+
const comment = Number(body.get("comment") ?? "");
|
|
1415
|
+
if (!Number.isSafeInteger(comment) || comment < 1)
|
|
1416
|
+
return refuse(response, who, 400, "Choose a saved note.", back);
|
|
1417
|
+
const dropped = dropFeedbackImage(store, id, comment, who.name, one => feedbackImageProblem(store, evidenceRoot, id, one), now);
|
|
1418
|
+
if (!dropped.ok && dropped.reason === "not-authorized")
|
|
1419
|
+
return refuse(response, who, 403, "Only a signed-in approver on this project can change saved feedback.", back);
|
|
1420
|
+
if (!dropped.ok)
|
|
1421
|
+
return refuse(response, who, 409, dropped.reason === "image-usable"
|
|
1422
|
+
? "That screenshot still verifies, so the note keeps it. Reload the result." : "That saved note changed or was already used. Reload the result.", back);
|
|
1423
|
+
return redirect(response, `${back}#request-changes`);
|
|
1424
|
+
}
|
|
1425
|
+
const image = parseFeedbackImage(body.get("image"));
|
|
1426
|
+
if (image === false)
|
|
1427
|
+
return refuse(response, who, 400, "Choose a screenshot from this result again. Your draft is unchanged.", back);
|
|
1395
1428
|
if (body.get("intent") === "revise") {
|
|
1396
1429
|
const result = requestResultChanges(store, evidenceRoot, {
|
|
1397
1430
|
run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
|
|
1398
1431
|
repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
|
|
1399
|
-
note: body.get("note") ?? "", path: body.get("path") ?? "", line: body.get("line") ?? "", request: body.get("request"),
|
|
1432
|
+
note: body.get("note") ?? "", path: body.get("path") ?? "", line: body.get("line") ?? "", request: body.get("request"), image,
|
|
1400
1433
|
}, now);
|
|
1401
1434
|
if (!result.ok) {
|
|
1402
1435
|
const request = body.get("request") ?? "";
|
|
1403
|
-
|
|
1436
|
+
// The queue's link can stop resolving when a newer attempt finishes. The exact
|
|
1437
|
+
// run record still hosts this account/run's recoverable feedback draft.
|
|
1438
|
+
const retryBase = image === null ? back : `/r/${id}?record=1`;
|
|
1439
|
+
const retryBack = /^[a-f0-9]{32}$/.test(request) ? `${retryBase}${retryBase.includes("?") ? "&" : "?"}conflict=${request}#request-changes` : retryBase;
|
|
1404
1440
|
return refuse(response, who, result.status, result.message, retryBack);
|
|
1405
1441
|
}
|
|
1406
1442
|
return redirect(response, revisionDestination(result.id, returnTo));
|
|
@@ -1435,7 +1471,17 @@ export function createTasksHandlers(runtime) {
|
|
|
1435
1471
|
const request = body.get("request");
|
|
1436
1472
|
const sourceKey = commentSourceKey(who.name, request);
|
|
1437
1473
|
const path = rawPath === "" ? null : rawPath;
|
|
1438
|
-
|
|
1474
|
+
// Freshness, custody and the insert in one write transaction: a newer attempt finishing in between can't leave a
|
|
1475
|
+
// stale screenshot attached (check and write together).
|
|
1476
|
+
const saved = store.transact(() => {
|
|
1477
|
+
const imageProblem = feedbackImageProblem(store, evidenceRoot, id, image);
|
|
1478
|
+
if (imageProblem !== null)
|
|
1479
|
+
return { problem: imageProblem };
|
|
1480
|
+
return { inserted: store.addDiffComment({ artifactId: terminal.id, runId: id, path, line, note: note.note, author: who.name, image, ...(sourceKey === undefined ? {} : { sourceKey }) }, now) };
|
|
1481
|
+
});
|
|
1482
|
+
if ("problem" in saved)
|
|
1483
|
+
return refuse(response, who, 409, saved.problem, `/r/${id}?record=1#request-changes`);
|
|
1484
|
+
const inserted = saved.inserted;
|
|
1439
1485
|
if (inserted === null && sourceKey !== undefined) {
|
|
1440
1486
|
// The token was used before (repair 2026-09-14, finding 1). A
|
|
1441
1487
|
// request identity is IMMUTABLE: it names exactly one note on
|
|
@@ -1449,7 +1495,7 @@ export function createTasksHandlers(runtime) {
|
|
|
1449
1495
|
if (earlier === null || earlier.run !== id) {
|
|
1450
1496
|
return refuse(response, who, 409, `this note's request identity was already used on another result (build #${earlier === null ? "?" : earlier.run}) — go back; your words are kept, and the form will carry a new identity`, conflictBack);
|
|
1451
1497
|
}
|
|
1452
|
-
if (earlier.path !== path || earlier.line !== line || earlier.note !== note.note) {
|
|
1498
|
+
if (earlier.path !== path || earlier.line !== line || earlier.note !== note.note || !sameFeedbackImage(earlier.image, image)) {
|
|
1453
1499
|
return refuse(response, who, 409, `this request identity already recorded a different note on this result (${earlier.path === null ? "no file" : `${earlier.path}${earlier.line === null ? "" : `:${earlier.line}`}`}: "${oneLineOf(earlier.note, 80)}") — go back; your edited words are kept as a new note, and the form will carry a new identity`, conflictBack);
|
|
1454
1500
|
}
|
|
1455
1501
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings kit's one script (docs/plans/settings-revamp.md §4), shipped as a page's functional script beside kit
|
|
3
|
+
* forms (settings-kit.ts). Without it every kit form works as today: the proof field shows inline and Save posts.
|
|
4
|
+
*
|
|
5
|
+
* With it, a kit form that asks for a proof:
|
|
6
|
+
* - moves its server-written proof field into the `<dialog>` inside the same form (the field never leaves the form);
|
|
7
|
+
* - opens the dialog on submit instead of posting, remembering the pressed button;
|
|
8
|
+
* - Confirm (its button, or Enter in the field) posts the form with that button (`requestSubmit(submitter)`, after the
|
|
9
|
+
* submit event that asked has finished), so its name=value and every hidden term go along unchanged; Cancel (or
|
|
10
|
+
* Escape) closes it and posts nothing, and focus returns to the button;
|
|
11
|
+
* - once a kit form posts, it is pending: further submits are dropped and its Save and Confirm are disabled (after the
|
|
12
|
+
* post has taken their values), until the page is shown again or the post was cancelled — never on a timer;
|
|
13
|
+
* - asks for nothing when the proof was left out (Passwordless) — there is no dialog then — and shows the
|
|
14
|
+
* identity-provider link when the sign-in swapped the field.
|
|
15
|
+
* Each section's Save bar names what changed. The script never reads or sets a field's value except to check that a
|
|
16
|
+
* required proof isn't empty; the server still proves everything.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SETTINGS_KIT_SCRIPT = "(function(){\nif(window.__soKit)return;window.__soKit=1;\nvar d=document;d.documentElement.classList.add(\"kit-js\");\nfunction label(el){var l=el.labels&&el.labels[0];if(!l&&el.closest)l=el.closest(\"fieldset\")&&el.closest(\"fieldset\").querySelector(\"legend\");return l?(l.textContent||\"\").replace(/\\s+/g,\" \").trim():(el.name||\"\");}\nfunction prep(f){if(f.__kit)return;f.__kit=1;\nvar bar=f.querySelector(\"[data-kit-state]\");if(bar&&bar.textContent===\"\")bar.textContent=bar.getAttribute(\"data-idle\")||\"\";\nif(!f.hasAttribute(\"data-kit-step-up\"))return;\nvar dl=f.querySelector(\"[data-kit-dialog]\"),p=f.querySelector(\":scope>[data-kit-proof]\");\nif(!dl||!p||typeof dl.showModal!==\"function\"){f.removeAttribute(\"data-kit-step-up\");return;}\ndl.querySelector(\"[data-kit-slot]\").appendChild(p);\np.querySelectorAll(\"[required]\").forEach(function(i){i.removeAttribute(\"required\");i.setAttribute(\"data-kit-required\",\"\");});\ndl.addEventListener(\"close\",function(){if(f.__kitSent)return;var back=f.__kitBy||f.querySelector(\".kit-save\");if(back&&back.focus)back.focus();});}\nfunction scan(root){if(root.querySelectorAll)root.querySelectorAll(\"form.kit-form\").forEach(prep);}\nscan(d);\nif(typeof MutationObserver!==\"undefined\")new MutationObserver(function(list){list.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches(\"form.kit-form\"))prep(n);scan(n);}});});}).observe(d.documentElement,{childList:true,subtree:true});\nfunction asks(dl){var slot=dl.querySelector(\"[data-kit-slot]\");return !!slot.querySelector(\"input:not([type=hidden]),a,.sso-step-up\");}\nfunction confirm(f){var dl=f.querySelector(\"[data-kit-dialog]\"),miss=null;\ndl.querySelectorAll(\"[data-kit-required]\").forEach(function(i){if(!miss&&i.value===\"\")miss=i;});\nif(miss){miss.setCustomValidity(\"Enter your password.\");miss.reportValidity();miss.focus();return;}\nvar by=f.__kitBy&&f.__kitBy.form===f?f.__kitBy:null;f.__kitSent=1;dl.close();\nsetTimeout(function(){f.__kitGo=1;try{by?f.requestSubmit(by):f.requestSubmit();}finally{f.__kitGo=0;}},0);}\nfunction buttons(f){return f.querySelectorAll(\".kit-save,[data-kit-confirm]\");}\nfunction release(f){f.__kitPending=0;f.__kitSent=0;buttons(f).forEach(function(b){b.disabled=false;});}\nfunction latch(f,e){f.__kitPending=1;setTimeout(function(){if(e.defaultPrevented){release(f);return;}\nbuttons(f).forEach(function(b){b.disabled=true;});},0);}\naddEventListener(\"pageshow\",function(){d.querySelectorAll(\"form.kit-form\").forEach(release);});\nd.addEventListener(\"submit\",function(e){var f=e.target;if(!f||!f.classList||!f.classList.contains(\"kit-form\"))return;prep(f);\nif(f.__kitPending){e.preventDefault();e.stopImmediatePropagation();return;}\nvar dl=f.hasAttribute(\"data-kit-step-up\")?f.querySelector(\"[data-kit-dialog]\"):null;\nif(f.__kitGo||!dl||!asks(dl)){latch(f,e);return;}\ne.preventDefault();e.stopImmediatePropagation();\nif(dl.open){confirm(f);return;}\nf.__kitBy=e.submitter||null;f.__kitSent=0;dl.showModal();\nvar first=dl.querySelector(\"[data-kit-slot] input:not([type=hidden]),[data-kit-slot] a\")||dl.querySelector(\"[data-kit-confirm]\");if(first)first.focus();},true);\nd.addEventListener(\"click\",function(e){var b=e.target&&e.target.closest?e.target.closest(\"[data-kit-confirm],[data-kit-cancel]\"):null;if(!b)return;\nvar dl=b.closest(\"[data-kit-dialog]\"),f=dl&&dl.closest(\"form\");if(!f)return;e.preventDefault();\nif(b.hasAttribute(\"data-kit-cancel\")){dl.close();return;}confirm(f);});\nd.addEventListener(\"input\",function(e){var t=e.target;if(t&&t.setCustomValidity&&t.hasAttribute&&t.hasAttribute(\"data-kit-required\"))t.setCustomValidity(\"\");},true);\nfunction changed(e){var t=e.target,f=t&&t.form;if(!f||!f.classList.contains(\"kit-form\")||t.closest(\"[data-kit-dialog]\")||t.type===\"hidden\")return;prep(f);\nvar bar=f.querySelector(\"[data-kit-save-bar]\"),state=f.querySelector(\"[data-kit-state]\");if(!bar||!state)return;\nf.__kitChanged=f.__kitChanged||[];var name=label(t);if(name&&f.__kitChanged.indexOf(name)<0)f.__kitChanged.push(name);\nbar.setAttribute(\"data-changed\",\"\");state.textContent=\"Changed: \"+f.__kitChanged.join(\", \");}\nd.addEventListener(\"input\",changed);d.addEventListener(\"change\",changed);\n})();";
|
|
19
|
+
/** The pages that wear the settings frame: one column, no Crew sheet, a breadcrumb under Settings. Here (not in
|
|
20
|
+
* settings-kit.ts) so the browser bundle reads it without the server's form contracts. */
|
|
21
|
+
export declare function isSettingsFrame(pathname: string): boolean;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings kit's one script (docs/plans/settings-revamp.md §4), shipped as a page's functional script beside kit
|
|
3
|
+
* forms (settings-kit.ts). Without it every kit form works as today: the proof field shows inline and Save posts.
|
|
4
|
+
*
|
|
5
|
+
* With it, a kit form that asks for a proof:
|
|
6
|
+
* - moves its server-written proof field into the `<dialog>` inside the same form (the field never leaves the form);
|
|
7
|
+
* - opens the dialog on submit instead of posting, remembering the pressed button;
|
|
8
|
+
* - Confirm (its button, or Enter in the field) posts the form with that button (`requestSubmit(submitter)`, after the
|
|
9
|
+
* submit event that asked has finished), so its name=value and every hidden term go along unchanged; Cancel (or
|
|
10
|
+
* Escape) closes it and posts nothing, and focus returns to the button;
|
|
11
|
+
* - once a kit form posts, it is pending: further submits are dropped and its Save and Confirm are disabled (after the
|
|
12
|
+
* post has taken their values), until the page is shown again or the post was cancelled — never on a timer;
|
|
13
|
+
* - asks for nothing when the proof was left out (Passwordless) — there is no dialog then — and shows the
|
|
14
|
+
* identity-provider link when the sign-in swapped the field.
|
|
15
|
+
* Each section's Save bar names what changed. The script never reads or sets a field's value except to check that a
|
|
16
|
+
* required proof isn't empty; the server still proves everything.
|
|
17
|
+
*/
|
|
18
|
+
export const SETTINGS_KIT_SCRIPT = `(function(){
|
|
19
|
+
if(window.__soKit)return;window.__soKit=1;
|
|
20
|
+
var d=document;d.documentElement.classList.add("kit-js");
|
|
21
|
+
function label(el){var l=el.labels&&el.labels[0];if(!l&&el.closest)l=el.closest("fieldset")&&el.closest("fieldset").querySelector("legend");return l?(l.textContent||"").replace(/\\s+/g," ").trim():(el.name||"");}
|
|
22
|
+
function prep(f){if(f.__kit)return;f.__kit=1;
|
|
23
|
+
var bar=f.querySelector("[data-kit-state]");if(bar&&bar.textContent==="")bar.textContent=bar.getAttribute("data-idle")||"";
|
|
24
|
+
if(!f.hasAttribute("data-kit-step-up"))return;
|
|
25
|
+
var dl=f.querySelector("[data-kit-dialog]"),p=f.querySelector(":scope>[data-kit-proof]");
|
|
26
|
+
if(!dl||!p||typeof dl.showModal!=="function"){f.removeAttribute("data-kit-step-up");return;}
|
|
27
|
+
dl.querySelector("[data-kit-slot]").appendChild(p);
|
|
28
|
+
p.querySelectorAll("[required]").forEach(function(i){i.removeAttribute("required");i.setAttribute("data-kit-required","");});
|
|
29
|
+
dl.addEventListener("close",function(){if(f.__kitSent)return;var back=f.__kitBy||f.querySelector(".kit-save");if(back&&back.focus)back.focus();});}
|
|
30
|
+
function scan(root){if(root.querySelectorAll)root.querySelectorAll("form.kit-form").forEach(prep);}
|
|
31
|
+
scan(d);
|
|
32
|
+
if(typeof MutationObserver!=="undefined")new MutationObserver(function(list){list.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches("form.kit-form"))prep(n);scan(n);}});});}).observe(d.documentElement,{childList:true,subtree:true});
|
|
33
|
+
function asks(dl){var slot=dl.querySelector("[data-kit-slot]");return !!slot.querySelector("input:not([type=hidden]),a,.sso-step-up");}
|
|
34
|
+
function confirm(f){var dl=f.querySelector("[data-kit-dialog]"),miss=null;
|
|
35
|
+
dl.querySelectorAll("[data-kit-required]").forEach(function(i){if(!miss&&i.value==="")miss=i;});
|
|
36
|
+
if(miss){miss.setCustomValidity("Enter your password.");miss.reportValidity();miss.focus();return;}
|
|
37
|
+
var by=f.__kitBy&&f.__kitBy.form===f?f.__kitBy:null;f.__kitSent=1;dl.close();
|
|
38
|
+
setTimeout(function(){f.__kitGo=1;try{by?f.requestSubmit(by):f.requestSubmit();}finally{f.__kitGo=0;}},0);}
|
|
39
|
+
function buttons(f){return f.querySelectorAll(".kit-save,[data-kit-confirm]");}
|
|
40
|
+
function release(f){f.__kitPending=0;f.__kitSent=0;buttons(f).forEach(function(b){b.disabled=false;});}
|
|
41
|
+
function latch(f,e){f.__kitPending=1;setTimeout(function(){if(e.defaultPrevented){release(f);return;}
|
|
42
|
+
buttons(f).forEach(function(b){b.disabled=true;});},0);}
|
|
43
|
+
addEventListener("pageshow",function(){d.querySelectorAll("form.kit-form").forEach(release);});
|
|
44
|
+
d.addEventListener("submit",function(e){var f=e.target;if(!f||!f.classList||!f.classList.contains("kit-form"))return;prep(f);
|
|
45
|
+
if(f.__kitPending){e.preventDefault();e.stopImmediatePropagation();return;}
|
|
46
|
+
var dl=f.hasAttribute("data-kit-step-up")?f.querySelector("[data-kit-dialog]"):null;
|
|
47
|
+
if(f.__kitGo||!dl||!asks(dl)){latch(f,e);return;}
|
|
48
|
+
e.preventDefault();e.stopImmediatePropagation();
|
|
49
|
+
if(dl.open){confirm(f);return;}
|
|
50
|
+
f.__kitBy=e.submitter||null;f.__kitSent=0;dl.showModal();
|
|
51
|
+
var first=dl.querySelector("[data-kit-slot] input:not([type=hidden]),[data-kit-slot] a")||dl.querySelector("[data-kit-confirm]");if(first)first.focus();},true);
|
|
52
|
+
d.addEventListener("click",function(e){var b=e.target&&e.target.closest?e.target.closest("[data-kit-confirm],[data-kit-cancel]"):null;if(!b)return;
|
|
53
|
+
var dl=b.closest("[data-kit-dialog]"),f=dl&&dl.closest("form");if(!f)return;e.preventDefault();
|
|
54
|
+
if(b.hasAttribute("data-kit-cancel")){dl.close();return;}confirm(f);});
|
|
55
|
+
d.addEventListener("input",function(e){var t=e.target;if(t&&t.setCustomValidity&&t.hasAttribute&&t.hasAttribute("data-kit-required"))t.setCustomValidity("");},true);
|
|
56
|
+
function changed(e){var t=e.target,f=t&&t.form;if(!f||!f.classList.contains("kit-form")||t.closest("[data-kit-dialog]")||t.type==="hidden")return;prep(f);
|
|
57
|
+
var bar=f.querySelector("[data-kit-save-bar]"),state=f.querySelector("[data-kit-state]");if(!bar||!state)return;
|
|
58
|
+
f.__kitChanged=f.__kitChanged||[];var name=label(t);if(name&&f.__kitChanged.indexOf(name)<0)f.__kitChanged.push(name);
|
|
59
|
+
bar.setAttribute("data-changed","");state.textContent="Changed: "+f.__kitChanged.join(", ");}
|
|
60
|
+
d.addEventListener("input",changed);d.addEventListener("change",changed);
|
|
61
|
+
})();`;
|
|
62
|
+
/** The pages that wear the settings frame: one column, no Crew sheet, a breadcrumb under Settings. Here (not in
|
|
63
|
+
* settings-kit.ts) so the browser bundle reads it without the server's form contracts. */
|
|
64
|
+
export function isSettingsFrame(pathname) {
|
|
65
|
+
return pathname === "/settings" || pathname.startsWith("/settings/") || pathname === "/setup" || pathname.startsWith("/setup/")
|
|
66
|
+
|| pathname === "/people" || pathname === "/mode" || pathname === "/control" || pathname === "/spend";
|
|
67
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings kit (docs/plans/settings-revamp.md §4): the frame's breadcrumb, and the pieces every settings page is
|
|
3
|
+
* rebuilt from — a section, its own sticky Save, the step-up dialog drawn from the action's own proof contract
|
|
4
|
+
* (settings-proofs.ts), field errors, secret inputs and choice groups. Server-rendered HTML that works without
|
|
5
|
+
* JavaScript; settings-kit-script.ts only turns the inline proof field into a dialog.
|
|
6
|
+
*
|
|
7
|
+
* Rules the helpers keep (and settings-kit.test.ts pins):
|
|
8
|
+
* - the proof field is written into the form's own server HTML, named as its handler reads it, labelled, and
|
|
9
|
+
* `current-password`; so sensitivity (serve.ts sendScreen) and the identity-provider swap (ssoStepUps) still see it;
|
|
10
|
+
* - a step-up field renders through `reauth`, so Passwordless leaves it out exactly as today; a credential or typed
|
|
11
|
+
* password field always renders;
|
|
12
|
+
* - a CSRF-only action renders no proof field and no dialog;
|
|
13
|
+
* - nothing here ever sets `value` on a proof or secret field;
|
|
14
|
+
* - handlers never read any of this: it only draws.
|
|
15
|
+
*/
|
|
16
|
+
import { type Html, type HtmlValue } from "./html.js";
|
|
17
|
+
import { isSettingsFrame } from "./settings-kit-script.js";
|
|
18
|
+
import { type ProofRow } from "./settings-proofs.js";
|
|
19
|
+
export { isSettingsFrame };
|
|
20
|
+
/** One breadcrumb: a link, or (the last) the page itself. */
|
|
21
|
+
export type Crumb = {
|
|
22
|
+
label: string;
|
|
23
|
+
href: string | null;
|
|
24
|
+
};
|
|
25
|
+
/** The text of a page's first h1 (tags dropped, the common entities read), or null. */
|
|
26
|
+
export declare function headingText(page: string): string | null;
|
|
27
|
+
/** The breadcrumb for a settings-frame page: "Settings / <this page>", or nothing on the hub and outside the frame. */
|
|
28
|
+
export declare function settingsCrumbs(pathname: string, title: string, page: string): Crumb[] | undefined;
|
|
29
|
+
/** The project the sidebar's picker names: the session's saved selection while this person may still see it, else
|
|
30
|
+
* the page's own effective project, else none — checked against the visible project list, so a selection that
|
|
31
|
+
* outlived the ceiling is never sent to the page. */
|
|
32
|
+
export declare function openProjectFor(saved: string | null, effective: string | null | undefined, visible: readonly {
|
|
33
|
+
path: string;
|
|
34
|
+
}[]): string | null;
|
|
35
|
+
/** The breadcrumb as server HTML (the page before scripts, and without them). */
|
|
36
|
+
export declare function crumbsHtml(crumbs: readonly Crumb[]): Html;
|
|
37
|
+
/** The password-manager opt-outs every secret input carries (a service's key is not the person's sign-in). */
|
|
38
|
+
export declare const SECRET_OPT_OUTS: {
|
|
39
|
+
readonly "data-1p-ignore": true;
|
|
40
|
+
readonly "data-lpignore": "true";
|
|
41
|
+
readonly "data-bwignore": true;
|
|
42
|
+
readonly "data-form-type": "other";
|
|
43
|
+
};
|
|
44
|
+
/** A field's message under it: tied to the field with aria-describedby, announced, and the field focused. */
|
|
45
|
+
export declare function fieldError(id: string, message: string): Html;
|
|
46
|
+
export type TextField = {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
label: string;
|
|
50
|
+
type?: "text" | "number" | "url" | "email";
|
|
51
|
+
/** What the person typed (kept after a validation error). Never a secret: secrets use secretInput. */
|
|
52
|
+
value?: string | null;
|
|
53
|
+
hint?: string;
|
|
54
|
+
error?: string | null;
|
|
55
|
+
attrs?: Readonly<Record<string, string | number | boolean | null | undefined>>;
|
|
56
|
+
};
|
|
57
|
+
/** A labelled text field; with an error it is aria-invalid, names its message and takes focus. */
|
|
58
|
+
export declare function textField(field: TextField): Html;
|
|
59
|
+
/** A secret that is not the person's own password (an API key, a bot token, a client secret): masked, never given a
|
|
60
|
+
* value, kept out of password managers. The field name is the handler's own wire name. */
|
|
61
|
+
export declare function secretInput(field: {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
label: string;
|
|
65
|
+
hint?: string;
|
|
66
|
+
required?: boolean;
|
|
67
|
+
error?: string | null;
|
|
68
|
+
}): Html;
|
|
69
|
+
/** A choice group: a fieldset with its legend, each option's detail tied to it. Nothing saves until Save. */
|
|
70
|
+
export declare function choiceGroup(group: {
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
legend: string;
|
|
74
|
+
checked: string | null;
|
|
75
|
+
options: readonly {
|
|
76
|
+
value: string;
|
|
77
|
+
label: string;
|
|
78
|
+
detail?: string;
|
|
79
|
+
}[];
|
|
80
|
+
error?: string | null;
|
|
81
|
+
}): Html;
|
|
82
|
+
/** Proofs that only a password typed now satisfies: no Passwordless session and no identity-provider re-check. */
|
|
83
|
+
export declare const typedOnly: (row: ProofRow) => boolean;
|
|
84
|
+
/** The proof field a row's handler reads, as today's forms write it; empty for a CSRF-only row. A step-up field goes
|
|
85
|
+
* through `reauth` (Passwordless leaves it out); a credential or typed field always shows. A typed-only field carries
|
|
86
|
+
* `data-so-typed`, so the identity-provider swap leaves it a real password field (keepTypedFields). Never a value. */
|
|
87
|
+
export declare function proofField(row: ProofRow, options?: {
|
|
88
|
+
label?: string;
|
|
89
|
+
}): Html;
|
|
90
|
+
/**
|
|
91
|
+
* The identity-provider swap (render-people.ts ssoStepUps) with typed-only proof fields kept: those handlers take only
|
|
92
|
+
* a password typed now, so the field must stay a real password input. The field is held out of the swap by its
|
|
93
|
+
* marker and put back as it was. Every other step-up field is swapped exactly as before.
|
|
94
|
+
*/
|
|
95
|
+
export declare function keepTypedFields<A extends unknown[]>(page: Html, swap: (page: Html, ...rest: A) => Html, ...rest: A): Html;
|
|
96
|
+
/** How the dialog explains a proof that Passwordless doesn't cover, in words; null where nothing needs saying. */
|
|
97
|
+
export declare function proofNote(row: ProofRow): string | null;
|
|
98
|
+
export type StepUpForm = {
|
|
99
|
+
/** The proof inventory row this form posts under. */
|
|
100
|
+
proof: string;
|
|
101
|
+
/** The exact route (one of the row's). */
|
|
102
|
+
action: string;
|
|
103
|
+
/** Unique on the page: the form, its dialog and its section hang from it. */
|
|
104
|
+
id: string;
|
|
105
|
+
/** The section's own fields. */
|
|
106
|
+
body: HtmlValue;
|
|
107
|
+
/** Hidden terms posted back unchanged (nonce, digest, fingerprint, the shown project…). */
|
|
108
|
+
hidden?: Readonly<Record<string, string | number | null | undefined>>;
|
|
109
|
+
/** The Save bar's words before anything changes, and its button. */
|
|
110
|
+
save?: {
|
|
111
|
+
label?: string;
|
|
112
|
+
idle?: string;
|
|
113
|
+
name?: string;
|
|
114
|
+
value?: string;
|
|
115
|
+
};
|
|
116
|
+
/** What the dialog says the person is agreeing to (the action's own words by default). */
|
|
117
|
+
confirm?: string;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* A section's form, posted to its existing route with its existing fields, and its sticky Save. When the row needs a
|
|
121
|
+
* proof, the proof field sits in the form's own HTML — inline (as today, which is how it works without JavaScript) and
|
|
122
|
+
* moved into a `<dialog>` inside the same form by settings-kit-script.ts, which opens it on submit, keeps the pressed
|
|
123
|
+
* button, and posts nothing on Cancel. The server still proves everything.
|
|
124
|
+
*/
|
|
125
|
+
export declare function stepUpForm(form: StepUpForm): Html;
|
|
126
|
+
/** A section: a heading, a one-line state, a problem the handler sent back (role=alert), then its form. */
|
|
127
|
+
export declare function kitSection(section: {
|
|
128
|
+
id: string;
|
|
129
|
+
title: string;
|
|
130
|
+
state?: HtmlValue;
|
|
131
|
+
problem?: string | null;
|
|
132
|
+
said?: string | null;
|
|
133
|
+
body: HtmlValue;
|
|
134
|
+
}): Html;
|
|
135
|
+
/** The kit's look: one 760px column, sections with no nested wells, the sticky Save, the dialog. The needs-you colour
|
|
136
|
+
* falls back to the warning token until S5 lands `--so-needs-you`. */
|
|
137
|
+
export declare const SETTINGS_KIT_CSS: string;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings kit (docs/plans/settings-revamp.md §4): the frame's breadcrumb, and the pieces every settings page is
|
|
3
|
+
* rebuilt from — a section, its own sticky Save, the step-up dialog drawn from the action's own proof contract
|
|
4
|
+
* (settings-proofs.ts), field errors, secret inputs and choice groups. Server-rendered HTML that works without
|
|
5
|
+
* JavaScript; settings-kit-script.ts only turns the inline proof field into a dialog.
|
|
6
|
+
*
|
|
7
|
+
* Rules the helpers keep (and settings-kit.test.ts pins):
|
|
8
|
+
* - the proof field is written into the form's own server HTML, named as its handler reads it, labelled, and
|
|
9
|
+
* `current-password`; so sensitivity (serve.ts sendScreen) and the identity-provider swap (ssoStepUps) still see it;
|
|
10
|
+
* - a step-up field renders through `reauth`, so Passwordless leaves it out exactly as today; a credential or typed
|
|
11
|
+
* password field always renders;
|
|
12
|
+
* - a CSRF-only action renders no proof field and no dialog;
|
|
13
|
+
* - nothing here ever sets `value` on a proof or secret field;
|
|
14
|
+
* - handlers never read any of this: it only draws.
|
|
15
|
+
*/
|
|
16
|
+
import { attributes, html, htmlString, joinHtml, postForm, reauth, replaceMarkup } from "./html.js";
|
|
17
|
+
import { isSettingsFrame } from "./settings-kit-script.js";
|
|
18
|
+
import { contractReads, PROOF_INVENTORY, proofRow } from "./settings-proofs.js";
|
|
19
|
+
export { isSettingsFrame };
|
|
20
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", "#x27": "'", nbsp: " " };
|
|
21
|
+
/** The text of a page's first h1 (tags dropped, the common entities read), or null. */
|
|
22
|
+
export function headingText(page) {
|
|
23
|
+
const found = /<h1\b[^>]*>([\s\S]*?)<\/h1>/i.exec(page);
|
|
24
|
+
if (found === null)
|
|
25
|
+
return null;
|
|
26
|
+
const text = found[1].replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|#39|#x27|nbsp);/g, (_whole, name) => ENTITIES[name] ?? "").replace(/\s+/g, " ").trim();
|
|
27
|
+
return text === "" ? null : text.slice(0, 80);
|
|
28
|
+
}
|
|
29
|
+
/** The breadcrumb for a settings-frame page: "Settings / <this page>", or nothing on the hub and outside the frame. */
|
|
30
|
+
export function settingsCrumbs(pathname, title, page) {
|
|
31
|
+
if (!isSettingsFrame(pathname) || pathname === "/settings")
|
|
32
|
+
return undefined;
|
|
33
|
+
const name = headingText(page) ?? (title.charAt(0).toUpperCase() + title.slice(1));
|
|
34
|
+
return [{ label: "Settings", href: "/settings" }, { label: name, href: null }];
|
|
35
|
+
}
|
|
36
|
+
/** The project the sidebar's picker names: the session's saved selection while this person may still see it, else
|
|
37
|
+
* the page's own effective project, else none — checked against the visible project list, so a selection that
|
|
38
|
+
* outlived the ceiling is never sent to the page. */
|
|
39
|
+
export function openProjectFor(saved, effective, visible) {
|
|
40
|
+
return [saved, effective ?? null].find((path) => path !== null && visible.some(one => one.path === path)) ?? null;
|
|
41
|
+
}
|
|
42
|
+
/** The breadcrumb as server HTML (the page before scripts, and without them). */
|
|
43
|
+
export function crumbsHtml(crumbs) {
|
|
44
|
+
return html `<nav class="kit-crumbs" aria-label="Breadcrumb"><ol>${crumbs.map(one => one.href === null
|
|
45
|
+
? html `<li aria-current="page">${one.label}</li>`
|
|
46
|
+
: html `<li><a href="${one.href}">${one.label}</a></li>`)}</ol></nav>`;
|
|
47
|
+
}
|
|
48
|
+
// ---- fields --------------------------------------------------------------------------------------------------------
|
|
49
|
+
/** The password-manager opt-outs every secret input carries (a service's key is not the person's sign-in). */
|
|
50
|
+
export const SECRET_OPT_OUTS = { "data-1p-ignore": true, "data-lpignore": "true", "data-bwignore": true, "data-form-type": "other" };
|
|
51
|
+
const errorId = (id) => `${id}-error`;
|
|
52
|
+
/** A field's message under it: tied to the field with aria-describedby, announced, and the field focused. */
|
|
53
|
+
export function fieldError(id, message) {
|
|
54
|
+
return html `<p class="kit-field-error" id="${errorId(id)}" role="alert">${message}</p>`;
|
|
55
|
+
}
|
|
56
|
+
/** A labelled text field; with an error it is aria-invalid, names its message and takes focus. */
|
|
57
|
+
export function textField(field) {
|
|
58
|
+
const hint = field.hint === undefined ? null : `${field.id}-hint`;
|
|
59
|
+
const described = [hint, field.error ? errorId(field.id) : null].filter(Boolean).join(" ");
|
|
60
|
+
return html `<div class="kit-field"><label for="${field.id}">${field.label}</label>\
|
|
61
|
+
${field.hint === undefined ? "" : html `<p class="kit-hint" id="${hint}">${field.hint}</p>`}\
|
|
62
|
+
<input${attributes({ id: field.id, name: field.name, type: field.type ?? "text", value: field.value ?? null, ...(field.attrs ?? {}),
|
|
63
|
+
"aria-invalid": field.error ? "true" : null, "aria-describedby": described === "" ? null : described, autofocus: field.error ? true : null })}>\
|
|
64
|
+
${field.error ? fieldError(field.id, field.error) : ""}</div>`;
|
|
65
|
+
}
|
|
66
|
+
/** A secret that is not the person's own password (an API key, a bot token, a client secret): masked, never given a
|
|
67
|
+
* value, kept out of password managers. The field name is the handler's own wire name. */
|
|
68
|
+
export function secretInput(field) {
|
|
69
|
+
const described = [field.hint === undefined ? null : `${field.id}-hint`, field.error ? errorId(field.id) : null].filter(Boolean).join(" ");
|
|
70
|
+
return html `<div class="kit-field"><label for="${field.id}">${field.label}</label>\
|
|
71
|
+
${field.hint === undefined ? "" : html `<p class="kit-hint" id="${field.id}-hint">${field.hint}</p>`}\
|
|
72
|
+
<input${attributes({ id: field.id, name: field.name, type: "password", autocomplete: "off", spellcheck: "false", autocapitalize: "none", "data-so-secret": true, ...SECRET_OPT_OUTS,
|
|
73
|
+
required: field.required === true, "aria-invalid": field.error ? "true" : null, "aria-describedby": described === "" ? null : described, autofocus: field.error ? true : null })}>\
|
|
74
|
+
${field.error ? fieldError(field.id, field.error) : ""}</div>`;
|
|
75
|
+
}
|
|
76
|
+
/** A choice group: a fieldset with its legend, each option's detail tied to it. Nothing saves until Save. */
|
|
77
|
+
export function choiceGroup(group) {
|
|
78
|
+
return html `<fieldset class="kit-choices"${group.error ? html ` aria-describedby="${errorId(group.id)}"` : ""}><legend>${group.legend}</legend>\
|
|
79
|
+
${group.options.map((one, at) => {
|
|
80
|
+
const id = `${group.id}-${at}`;
|
|
81
|
+
return html `<label class="kit-choice" for="${id}"><input${attributes({ type: "radio", id, name: group.name, value: one.value, checked: one.value === group.checked, "aria-describedby": one.detail === undefined ? null : `${id}-detail` })}>\
|
|
82
|
+
<span>${one.label}</span>${one.detail === undefined ? "" : html `<span class="kit-choice-detail" id="${id}-detail">${one.detail}</span>`}</label>`;
|
|
83
|
+
})}${group.error ? fieldError(group.id, group.error) : ""}</fieldset>`;
|
|
84
|
+
}
|
|
85
|
+
// ---- proof ---------------------------------------------------------------------------------------------------------
|
|
86
|
+
/** Proofs that only a password typed now satisfies: no Passwordless session and no identity-provider re-check. */
|
|
87
|
+
export const typedOnly = (row) => row.proof === "typed-password" || row.proof === "typed-now-operator";
|
|
88
|
+
/** The proof field a row's handler reads, as today's forms write it; empty for a CSRF-only row. A step-up field goes
|
|
89
|
+
* through `reauth` (Passwordless leaves it out); a credential or typed field always shows. A typed-only field carries
|
|
90
|
+
* `data-so-typed`, so the identity-provider swap leaves it a real password field (keepTypedFields). Never a value. */
|
|
91
|
+
export function proofField(row, options = {}) {
|
|
92
|
+
if (row.field === null || row.proof === "none")
|
|
93
|
+
return html ``;
|
|
94
|
+
if (!contractReads(row, row.field))
|
|
95
|
+
throw new Error(`${row.id}: its form doesn't read ${row.field}`);
|
|
96
|
+
const label = options.label ?? "Your Toolroll password";
|
|
97
|
+
if (typedOnly(row))
|
|
98
|
+
return html `<label>${label}<input type="password" name="${row.field}" data-so-typed autocomplete="current-password" required></label>`;
|
|
99
|
+
const field = html `<label>${label}<input type="password" name="${row.field}" autocomplete="current-password" required></label>`;
|
|
100
|
+
return row.proof === "step-up" ? reauth(field) : field;
|
|
101
|
+
}
|
|
102
|
+
const TYPED_FIELD = /<input type="password" name="([a-z-]+)" data-so-typed autocomplete="current-password" required>/g;
|
|
103
|
+
const HELD_FIELD = /<input type="password" name="([a-z-]+)" data-so-typed data-so-typed-held required>/g;
|
|
104
|
+
/**
|
|
105
|
+
* The identity-provider swap (render-people.ts ssoStepUps) with typed-only proof fields kept: those handlers take only
|
|
106
|
+
* a password typed now, so the field must stay a real password input. The field is held out of the swap by its
|
|
107
|
+
* marker and put back as it was. Every other step-up field is swapped exactly as before.
|
|
108
|
+
*/
|
|
109
|
+
export function keepTypedFields(page, swap, ...rest) {
|
|
110
|
+
const held = replaceMarkup(page, TYPED_FIELD, (_whole, name) => html `<input type="password" name="${name}" data-so-typed data-so-typed-held required>`);
|
|
111
|
+
return replaceMarkup(swap(held, ...rest), HELD_FIELD, (_whole, name) => html `<input type="password" name="${name}" data-so-typed autocomplete="current-password" required>`);
|
|
112
|
+
}
|
|
113
|
+
/** The rows that share this row's route: one handler's variants. */
|
|
114
|
+
const variantsOf = (row) => PROOF_INVENTORY.filter(one => one !== row && one.routes.some(route => row.routes.includes(route)));
|
|
115
|
+
/** How the dialog explains a proof that Passwordless doesn't cover, in words; null where nothing needs saying. */
|
|
116
|
+
export function proofNote(row) {
|
|
117
|
+
if (row.proof === "credential")
|
|
118
|
+
return "This changes a way in, so it always takes your password.";
|
|
119
|
+
if (row.proof === "typed-password")
|
|
120
|
+
return "This always takes your password, typed.";
|
|
121
|
+
if (row.proof === "typed-now-operator")
|
|
122
|
+
return "Widening this takes an instance operator's password, typed now.";
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A section's form, posted to its existing route with its existing fields, and its sticky Save. When the row needs a
|
|
127
|
+
* proof, the proof field sits in the form's own HTML — inline (as today, which is how it works without JavaScript) and
|
|
128
|
+
* moved into a `<dialog>` inside the same form by settings-kit-script.ts, which opens it on submit, keeps the pressed
|
|
129
|
+
* button, and posts nothing on Cancel. The server still proves everything.
|
|
130
|
+
*/
|
|
131
|
+
export function stepUpForm(form) {
|
|
132
|
+
const row = proofRow(form.proof);
|
|
133
|
+
if (!row.routes.includes(form.action))
|
|
134
|
+
throw new Error(`${row.id} doesn't post to ${form.action}`);
|
|
135
|
+
const hidden = form.hidden ?? {};
|
|
136
|
+
for (const name of Object.keys(hidden))
|
|
137
|
+
if (row.field === name || row.secrets.includes(name))
|
|
138
|
+
throw new Error(`${row.id}: ${name} is never a hidden value`);
|
|
139
|
+
// The exact variant: its own selector must be in the hidden terms, and no other variant's may be.
|
|
140
|
+
if (row.select !== undefined && String(hidden[row.select.field] ?? "") !== row.select.value)
|
|
141
|
+
throw new Error(`${row.id} posts ${row.select.field}=${row.select.value}`);
|
|
142
|
+
for (const other of variantsOf(row))
|
|
143
|
+
if (other.select !== undefined && String(hidden[other.select.field] ?? "") === other.select.value)
|
|
144
|
+
throw new Error(`${form.action} with ${other.select.field}=${other.select.value} is ${other.id}, not ${row.id}`);
|
|
145
|
+
const proof = proofField(row);
|
|
146
|
+
const asks = htmlString(proof) !== "";
|
|
147
|
+
const note = proofNote(row);
|
|
148
|
+
const save = form.save ?? {};
|
|
149
|
+
const submit = html `<button${attributes({ type: "submit", class: "kit-save", name: save.name ?? null, value: save.value ?? null })}>${save.label ?? "Save"}</button>`;
|
|
150
|
+
const dialog = asks ? html `<dialog class="kit-dialog" id="${form.id}-dialog" aria-labelledby="${form.id}-dialog-title" data-kit-dialog>\
|
|
151
|
+
<h2 id="${form.id}-dialog-title">Confirm it's you</h2><p class="kit-dialog-terms">${form.confirm ?? row.action}${row.when === undefined ? "" : html ` <span class="kit-hint">(${row.when})</span>`}</p>\
|
|
152
|
+
${note === null ? "" : html `<p class="kit-hint">${note}</p>`}<div class="kit-dialog-slot" data-kit-slot></div>\
|
|
153
|
+
<div class="kit-dialog-actions"><button type="button" class="kit-cancel" data-kit-cancel>Cancel</button><button type="button" class="kit-confirm" data-kit-confirm>${save.label ?? "Save"}</button></div></dialog>` : "";
|
|
154
|
+
return postForm(form.action, joinHtml([
|
|
155
|
+
form.body,
|
|
156
|
+
asks ? html `<div class="kit-proof" data-kit-proof>${proof}</div>` : "",
|
|
157
|
+
html `<div class="kit-save-bar" data-kit-save-bar><span class="kit-save-state" aria-live="polite" data-kit-state data-idle="${save.idle ?? "No changes"}"></span>${submit}</div>`,
|
|
158
|
+
dialog,
|
|
159
|
+
]), { hidden, attrs: { id: form.id, class: "kit-form", "data-kit-form": row.id, "data-kit-step-up": asks ? "" : null } });
|
|
160
|
+
}
|
|
161
|
+
/** A section: a heading, a one-line state, a problem the handler sent back (role=alert), then its form. */
|
|
162
|
+
export function kitSection(section) {
|
|
163
|
+
return html `<section class="kit-section" id="${section.id}" aria-labelledby="${section.id}-title"><h2 id="${section.id}-title">${section.title}</h2>\
|
|
164
|
+
${section.state === undefined ? "" : html `<p class="kit-state">${section.state}</p>`}\
|
|
165
|
+
${section.problem ? html `<p class="kit-problem" role="alert">${section.problem}</p>` : ""}\
|
|
166
|
+
${section.said ? html `<p class="kit-said" role="status">${section.said}</p>` : ""}${section.body}</section>`;
|
|
167
|
+
}
|
|
168
|
+
/** The kit's look: one 760px column, sections with no nested wells, the sticky Save, the dialog. The needs-you colour
|
|
169
|
+
* falls back to the warning token until S5 lands `--so-needs-you`. */
|
|
170
|
+
export const SETTINGS_KIT_CSS = ":root{--so-settings-width:760px}" +
|
|
171
|
+
".kit-crumbs ol{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0;padding:0;list-style:none;font-size:14px;color:var(--so-muted)}" +
|
|
172
|
+
".kit-crumbs li+li::before{content:'/';margin-right:6px;color:var(--so-muted)}.kit-crumbs a{color:var(--so-muted);text-decoration:none}.kit-crumbs a:hover{color:var(--so-ink);text-decoration:underline}" +
|
|
173
|
+
".kit-crumbs [aria-current]{color:var(--so-ink);font-weight:600}" +
|
|
174
|
+
".kit-section{margin:0 0 32px}.kit-section>h2{margin:0 0 4px;font-size:16px}.kit-state{margin:0 0 12px;color:var(--so-muted)}" +
|
|
175
|
+
".kit-problem{margin:0 0 12px;padding:10px 12px;border:1px solid var(--so-danger);border-radius:8px;color:var(--so-danger)}.kit-said{margin:0 0 12px;color:var(--so-muted)}" +
|
|
176
|
+
".kit-field{margin:0 0 14px}.kit-hint{margin:2px 0 0;font-size:13px;color:var(--so-muted)}" +
|
|
177
|
+
".kit-field-error{margin:4px 0 0;font-size:13px;color:var(--so-danger)}.kit-field [aria-invalid=true]{border-color:var(--so-danger)}" +
|
|
178
|
+
".kit-choices{margin:0 0 14px;padding:0;border:0}.kit-choices legend{padding:0;margin:0 0 6px;font-weight:600}" +
|
|
179
|
+
".kit-choice{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:start;min-height:44px;padding:8px 0;margin:0}.kit-choice input{margin:3px 0 0}.kit-choice-detail{grid-column:2;font-size:13px;color:var(--so-muted)}" +
|
|
180
|
+
".kit-save-bar{position:sticky;bottom:0;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:12px;margin:16px 0 0;padding:10px 0;border-top:1px solid var(--so-line);background:var(--so-paper)}" +
|
|
181
|
+
".kit-save-state{color:var(--so-muted);font-size:14px}.kit-save-bar[data-changed] .kit-save-state{color:var(--so-ink)}" +
|
|
182
|
+
".kit-js .kit-form[data-kit-step-up]>.kit-proof{display:none}" +
|
|
183
|
+
".kit-dialog{max-width:min(440px,calc(100vw - 32px));padding:20px;border:1px solid var(--so-line);border-radius:12px;background:var(--so-paper);color:var(--so-ink)}" +
|
|
184
|
+
".kit-dialog::backdrop{background:rgb(0 0 0 / .35)}.kit-dialog h2{margin:0 0 6px;font-size:17px}.kit-dialog-slot .kit-proof{display:block}" +
|
|
185
|
+
".kit-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0}" +
|
|
186
|
+
"@media (max-width:900px){.kit-save-bar button,.kit-dialog-actions button,.kit-crumbs a{min-height:44px}.kit-crumbs a{display:inline-flex;align-items:center}}";
|