@zq-silk/yui 2.14.0 → 2.15.0
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/dist/config/configCatalog.js +1 -1
- package/dist/controlPlaneCli.js +1 -1
- package/dist/job/projectSync.js +3 -3
- package/dist/storage/sqliteSchema.js +12 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
- package/dist/web/assets/assetManifest.js +23 -1
- package/dist/web/assets/client/app/login.js +156 -0
- package/dist/web/assets/client/i18n/en.js +18 -1
- package/dist/web/assets/client/i18n/zh-CN.js +18 -1
- package/dist/web/assets/client/index.js +4 -0
- package/dist/web/assets/client/lib/api.js +5 -0
- package/dist/web/assets/client/lib/session.js +21 -0
- package/dist/web/assets/client/views/dock/terminal.js +4 -1
- package/dist/web/assets/shared/icons.js +1 -0
- package/dist/web/assets/shell/login.js +56 -0
- package/dist/web/assets/styles/index.js +2 -0
- package/dist/web/assets/styles/views/login.js +42 -0
- package/dist/web/webAccess.js +129 -9
- package/dist/web/webServer.js +170 -14
- package/docs/release-workflow.md +45 -0
- package/docs/release-workflow.zh-CN.md +34 -0
- package/docs/storage-baseline.md +7 -1
- package/docs/storage-baseline.zh-CN.md +6 -1
- package/docs/testing/verification-levels.md +77 -11
- package/docs/testing/verification-levels.zh-CN.md +63 -9
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ export const CONFIG_DOMAINS = ["system", "runtime", "workflow", "resources", "to
|
|
|
8
8
|
export const CONFIG_DEFINITIONS = Object.freeze([
|
|
9
9
|
{ key: "default-agent", domain: "system", property: "defaultAgent", label: "Default Agent", summary: "Configured Agent used when a Task or Role does not select one explicitly.", takesEffect: "Future Role and Task defaults; existing bindings are unchanged." },
|
|
10
10
|
{ key: "default-workspace", domain: "system", property: "defaultWorkspace", label: "Default workspace", summary: "Absolute workspace root outside YUI_HOME used when no workspace is selected.", takesEffect: "Future workspace selection; existing Roles and worktrees are unchanged." },
|
|
11
|
-
{ key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering; durable
|
|
11
|
+
{ key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps and new default Project sync plans (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering and newly installed Project sync plans; existing Schedule rules stay unchanged and durable timestamps remain UTC." },
|
|
12
12
|
{ key: "reconciliation-interval-seconds", domain: "runtime", property: "reconciliationIntervalSeconds", label: "Reconciliation interval", summary: "Recovery reconciliation interval, 5-300 seconds (default: 120).", takesEffect: "The running Controller is refreshed after the value is saved." },
|
|
13
13
|
{ key: "controller-task-concurrency", domain: "runtime", property: "controllerTaskConcurrency", label: "Controller Task concurrency", summary: "Maximum Tasks reconciled concurrently, 1-32 (default: 4).", takesEffect: "After the Controller restarts." },
|
|
14
14
|
{ key: "runtime-health", domain: "runtime", property: "runtimeHealth", label: "Runtime health thresholds", summary: "Quiet, checkpoint-overdue, and read-only diagnostic thresholds in seconds; values must be strictly increasing (defaults: 300/900/1800).", takesEffect: "CLI and Web projections immediately; scheduler diagnostics after the Controller restarts." },
|
package/dist/controlPlaneCli.js
CHANGED
|
@@ -689,7 +689,7 @@ export async function main() {
|
|
|
689
689
|
const describe = (status) => status
|
|
690
690
|
? `Web listener: ${status.state}\n`
|
|
691
691
|
+ (status.alreadyRunning ? "Existing listener reused; credentials were not reloaded.\n" : "")
|
|
692
|
-
+ (status.authentication === "
|
|
692
|
+
+ (status.authentication === "password" ? "Browser sign-in: open the URL and enter the password from your private credential file on the Yui sign-in page.\n" : "")
|
|
693
693
|
+ (status.urls ?? []).map(url => `Yui web control room: ${url}\n`).join("")
|
|
694
694
|
+ (status.warnings ?? []).map(warning => `Warning: ${warning}\n`).join("")
|
|
695
695
|
: "Web is not running. Start it explicitly; Controller recovery does not restore listeners.\n";
|
package/dist/job/projectSync.js
CHANGED
|
@@ -7,9 +7,9 @@ import { requireRefreshProject, projectRefreshTargetDigest } from "../repository
|
|
|
7
7
|
import { createJobDefinition, reviseJobDefinition, validateJobDefinition } from "./jobDefinition.js";
|
|
8
8
|
import { nextScheduleDue, validateScheduleRule } from "../schedule/schedule.js";
|
|
9
9
|
import { scheduleServiceBinding } from "./scheduleService.js";
|
|
10
|
+
import { resolveTimeZone } from "../config/timeZone.js";
|
|
10
11
|
const scope = { kind: "global" };
|
|
11
12
|
const unavailable = "Project sync unavailable: ";
|
|
12
|
-
export const PROJECT_SYNC_INTERVAL_MS = 6 * 60 * 60_000;
|
|
13
13
|
/** Called under the maintenance lock immediately before Git, not a timer probe. */
|
|
14
14
|
export function requireProjectSyncCheckout(store, job) {
|
|
15
15
|
requireProjectSyncTarget(store, job);
|
|
@@ -86,8 +86,8 @@ export function reconcileProjectSync(store, now, onError = () => { }) {
|
|
|
86
86
|
reason = unavailable + (error instanceof Error ? error.message : String(error));
|
|
87
87
|
}
|
|
88
88
|
if (!plan) {
|
|
89
|
-
const rule = { kind: "
|
|
90
|
-
|
|
89
|
+
const rule = { kind: "cron", expression: "0 */6 * * *",
|
|
90
|
+
timezone: resolveTimeZone(tx.getConfig().timeZone) };
|
|
91
91
|
const grantId = randomUUID();
|
|
92
92
|
plan = { schemaVersion: 1, id, scope, jobId: id, target: { kind: "existing-job" }, rule,
|
|
93
93
|
builtin: { projectId, enabled: true }, revision: 1, generation: 1,
|
|
@@ -10,6 +10,7 @@ import { GLOBAL_SESSION_TABLE_SQL, SESSION_HISTORY_UPGRADE_SQL } from "./session
|
|
|
10
10
|
import { migrateJobDefinitions } from "./upgrade/jobDefinitionMigration.js";
|
|
11
11
|
import { migrateJobBusinessResults } from "./upgrade/jobBusinessResultMigration.js";
|
|
12
12
|
import { migrateScheduleDispatch } from "./upgrade/scheduleDispatchMigration.js";
|
|
13
|
+
import { migrateProjectSyncClock } from "./upgrade/projectSyncClockMigration.js";
|
|
13
14
|
import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION, STORAGE_FORMAT, isMinorStorageUpgrade, isStorageVersion, storageVersionParts } from "./storageVersions.js";
|
|
14
15
|
export { BASELINE_SCHEMA_SQL };
|
|
15
16
|
export const SQLITE_SCHEMA_TABLES = [...BASELINE_SCHEMA_TABLES, "job_definitions", "global_job_runs", "schedules", "agent_job_runs"];
|
|
@@ -233,6 +234,15 @@ const MINOR_UPGRADES = Object.freeze([{
|
|
|
233
234
|
// Existing records and immutable artifacts remain byte-for-byte unchanged.
|
|
234
235
|
// Declares Agent definitions/Runs and optional Job ownership of a non-Task Role.
|
|
235
236
|
sql: AGENT_JOB_SCHEMA_SQL
|
|
237
|
+
}, {
|
|
238
|
+
fromVersion: "1.20",
|
|
239
|
+
toVersion: "1.21",
|
|
240
|
+
name: "project-sync-clock",
|
|
241
|
+
introducedIn: "next",
|
|
242
|
+
sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
243
|
+
targetChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
244
|
+
sql: "",
|
|
245
|
+
dataMigration: "project-sync-clock"
|
|
236
246
|
}]);
|
|
237
247
|
export function storageMinorUpgradePlan(from) {
|
|
238
248
|
if (from === CURRENT_STORAGE_VERSION)
|
|
@@ -363,6 +373,8 @@ export function applySqliteMinorUpgrades(db, home, createdRoots = []) {
|
|
|
363
373
|
migrateJobBusinessResults(db);
|
|
364
374
|
if (step.dataMigration === "schedule-dispatch")
|
|
365
375
|
migrateScheduleDispatch(db);
|
|
376
|
+
if (step.dataMigration === "project-sync-clock")
|
|
377
|
+
migrateProjectSyncClock(db);
|
|
366
378
|
const { major, minor } = storageVersionParts(step.toVersion);
|
|
367
379
|
db.prepare("UPDATE storage_schema SET major=?,minor=?,checksum=? WHERE id=1")
|
|
368
380
|
.run(major, minor, step.targetChecksum);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolveTimeZone } from "../../config/timeZone.js";
|
|
2
|
+
import { nextScheduleDue, validateSchedule } from "../../schedule/schedule.js";
|
|
3
|
+
/** 1.20 had no rule-edit provenance. Only revision 1 proves no user mutation.
|
|
4
|
+
* Higher revisions may be dispatch, recovery OR an equal-valued user edit;
|
|
5
|
+
* preserve them instead of guessing from interval values or generation.
|
|
6
|
+
*/
|
|
7
|
+
export function migrateProjectSyncClock(db) {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const rows = db.prepare("SELECT rowid,scope_key,schedule_id,payload FROM schedules").all();
|
|
10
|
+
for (const row of rows) {
|
|
11
|
+
const old = JSON.parse(row.payload);
|
|
12
|
+
if (!old.builtin || old.revision !== 1)
|
|
13
|
+
continue;
|
|
14
|
+
validateSchedule(old);
|
|
15
|
+
// Confirm the declared install shape, in addition to the mutation evidence.
|
|
16
|
+
if (old.generation !== 1 || old.rule.kind !== "interval" || old.rule.everyMs !== 21_600_000
|
|
17
|
+
|| Date.parse(old.rule.anchor) !== Date.parse(old.createdAt) + 21_600_000)
|
|
18
|
+
continue;
|
|
19
|
+
const config = db.prepare("SELECT payload FROM config WHERE id=1").get();
|
|
20
|
+
const rule = { kind: "cron", expression: "0 */6 * * *",
|
|
21
|
+
timezone: resolveTimeZone(config ? JSON.parse(config.payload).timeZone : undefined) };
|
|
22
|
+
const after = new Date(Math.max(now.getTime(), Date.parse(old.processedThrough ?? "") || 0));
|
|
23
|
+
const next = validateSchedule({ ...old, rule, revision: old.revision + 1, generation: old.generation + 1,
|
|
24
|
+
processedThrough: after.toISOString(), nextDue: nextScheduleDue(rule, after), updatedAt: now.toISOString() });
|
|
25
|
+
db.prepare("INSERT INTO storage_migration_archive(migration_version,family,record_key,payload) VALUES(?,?,?,?)")
|
|
26
|
+
.run(21, "schedules", `${row.scope_key}/${row.schedule_id}`, row.payload);
|
|
27
|
+
db.prepare("UPDATE schedules SET payload=?,next_due=? WHERE rowid=?")
|
|
28
|
+
.run(JSON.stringify(next), next.nextDue, row.rowid);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -8,6 +8,7 @@ import { APP_SCRIPT, CLIENT_MODULES } from "./client/index.js";
|
|
|
8
8
|
import { THEME_BOOTSTRAP_SCRIPT } from "./client/lib/theme.js";
|
|
9
9
|
import { FONT_WOFF2_BASE64 } from "./fontData.js";
|
|
10
10
|
import { DASHBOARD_HTML } from "./shell/index.js";
|
|
11
|
+
import { LOGIN_HTML } from "./shell/login.js";
|
|
11
12
|
import { STYLESHEETS } from "./styles/index.js";
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const CSS = "text/css; charset=utf-8";
|
|
@@ -22,7 +23,28 @@ export const WEB_ASSETS = Object.freeze({
|
|
|
22
23
|
"/assets/vendor/addon-fit.mjs": vendorAsset("@xterm/addon-fit/lib/addon-fit.mjs", JS),
|
|
23
24
|
"/assets/vendor/xterm.css": vendorAsset("@xterm/xterm/css/xterm.css", CSS)
|
|
24
25
|
});
|
|
25
|
-
export { DASHBOARD_HTML };
|
|
26
|
+
export { DASHBOARD_HTML, LOGIN_HTML };
|
|
27
|
+
// The sign-in document's static closure (its stylesheets, fonts, theme script
|
|
28
|
+
// and module import graph). Only these assets are served without a session;
|
|
29
|
+
// every other asset, like every document and API, requires sign-in.
|
|
30
|
+
const LOGIN_ASSETS = (() => {
|
|
31
|
+
const found = new Set();
|
|
32
|
+
const visit = (body) => {
|
|
33
|
+
for (const [path] of body.matchAll(/\/assets\/[\w./-]+/g)) {
|
|
34
|
+
const asset = WEB_ASSETS[path];
|
|
35
|
+
if (!asset || found.has(path))
|
|
36
|
+
continue;
|
|
37
|
+
found.add(path);
|
|
38
|
+
if (asset.encoding !== "base64")
|
|
39
|
+
visit(asset.body);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
visit(LOGIN_HTML);
|
|
43
|
+
return found;
|
|
44
|
+
})();
|
|
45
|
+
export function isLoginAsset(pathname) {
|
|
46
|
+
return LOGIN_ASSETS.has(pathname);
|
|
47
|
+
}
|
|
26
48
|
// One content revision covers the complete import graph: changing a dependency
|
|
27
49
|
// invalidates its importers too. Only these static assets may be cached.
|
|
28
50
|
export const WEB_ASSET_VERSION = createHash("sha256")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export const LOGIN_SCRIPT = String.raw `
|
|
2
|
+
// Sign-in page controller. Posts the password once per submission as
|
|
3
|
+
// same-origin JSON; the server sets an HttpOnly session cookie and returns a
|
|
4
|
+
// validated same-origin destination. The password is never written to the
|
|
5
|
+
// URL, storage or console, and the field is cleared after a rejection.
|
|
6
|
+
import { createI18n } from "/assets/js/lib/i18n.js";
|
|
7
|
+
|
|
8
|
+
const form = document.querySelector("#login-form");
|
|
9
|
+
const password = document.querySelector("#login-password");
|
|
10
|
+
const reveal = document.querySelector("#login-reveal");
|
|
11
|
+
const submit = document.querySelector("#login-submit");
|
|
12
|
+
const submitLabel = document.querySelector("#login-submit-label");
|
|
13
|
+
const errorBox = document.querySelector("#login-error");
|
|
14
|
+
const notice = document.querySelector("#login-notice");
|
|
15
|
+
const transport = document.querySelector("#login-transport");
|
|
16
|
+
|
|
17
|
+
const i18n = createI18n(document.querySelector("#locale-select"));
|
|
18
|
+
const t = i18n.t;
|
|
19
|
+
const params = new URLSearchParams(window.location.search);
|
|
20
|
+
const next = params.get("next") || "/";
|
|
21
|
+
const reason = params.get("reason");
|
|
22
|
+
let busy = false;
|
|
23
|
+
let lockedUntil = 0;
|
|
24
|
+
let lockTimer = null;
|
|
25
|
+
let currentError = null;
|
|
26
|
+
|
|
27
|
+
function isLoopbackHost(host) {
|
|
28
|
+
return host === "localhost" || host === "[::1]" || /^127\./.test(host);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function showError(key, values) {
|
|
32
|
+
currentError = { key: key, values: values || {} };
|
|
33
|
+
let text = t(key);
|
|
34
|
+
Object.keys(currentError.values).forEach(function (name) { text = text.split("{" + name + "}").join(String(currentError.values[name])); });
|
|
35
|
+
errorBox.textContent = text;
|
|
36
|
+
errorBox.hidden = false;
|
|
37
|
+
password.setAttribute("aria-invalid", key === "login.error.network" || key === "login.error.origin" ? "false" : "true");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clearError() {
|
|
41
|
+
currentError = null;
|
|
42
|
+
errorBox.hidden = true;
|
|
43
|
+
errorBox.textContent = "";
|
|
44
|
+
password.setAttribute("aria-invalid", "false");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setBusy(value) {
|
|
48
|
+
busy = value;
|
|
49
|
+
form.setAttribute("aria-busy", value ? "true" : "false");
|
|
50
|
+
password.readOnly = value;
|
|
51
|
+
updateSubmit();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function updateSubmit() {
|
|
55
|
+
const locked = Date.now() < lockedUntil;
|
|
56
|
+
submit.disabled = busy || locked;
|
|
57
|
+
submitLabel.textContent = t(busy ? "login.submitting" : "login.submit");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderStatic() {
|
|
61
|
+
document.title = t("login.title");
|
|
62
|
+
if (reason === "expired" || reason === "signed-out") {
|
|
63
|
+
notice.className = "login-notice " + (reason === "expired" ? "tone-warn" : "tone-ok");
|
|
64
|
+
notice.textContent = t(reason === "expired" ? "login.notice.expired" : "login.notice.signedOut");
|
|
65
|
+
notice.hidden = false;
|
|
66
|
+
}
|
|
67
|
+
const revealed = reveal.getAttribute("aria-pressed") === "true";
|
|
68
|
+
reveal.setAttribute("aria-label", t(revealed ? "login.hide" : "login.show"));
|
|
69
|
+
reveal.title = t(revealed ? "login.hide" : "login.show");
|
|
70
|
+
if (currentError) showError(currentError.key, currentError.values);
|
|
71
|
+
updateSubmit();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function lockFor(seconds) {
|
|
75
|
+
lockedUntil = Date.now() + seconds * 1000;
|
|
76
|
+
window.clearInterval(lockTimer);
|
|
77
|
+
const tick = function () {
|
|
78
|
+
const remaining = Math.ceil((lockedUntil - Date.now()) / 1000);
|
|
79
|
+
if (remaining <= 0) {
|
|
80
|
+
window.clearInterval(lockTimer);
|
|
81
|
+
lockTimer = null;
|
|
82
|
+
clearError();
|
|
83
|
+
updateSubmit();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
showError("login.error.throttled", { seconds: remaining });
|
|
87
|
+
updateSubmit();
|
|
88
|
+
};
|
|
89
|
+
tick();
|
|
90
|
+
lockTimer = window.setInterval(tick, 1000);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
reveal.addEventListener("click", function () {
|
|
94
|
+
const revealed = reveal.getAttribute("aria-pressed") !== "true";
|
|
95
|
+
reveal.setAttribute("aria-pressed", revealed ? "true" : "false");
|
|
96
|
+
password.type = revealed ? "text" : "password";
|
|
97
|
+
renderStatic();
|
|
98
|
+
password.focus();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
password.addEventListener("input", function () {
|
|
102
|
+
if (currentError && currentError.key !== "login.error.throttled") clearError();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
form.addEventListener("submit", async function (event) {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
if (busy || Date.now() < lockedUntil) return;
|
|
108
|
+
if (!password.value) {
|
|
109
|
+
showError("login.error.empty");
|
|
110
|
+
password.focus();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
clearError();
|
|
114
|
+
setBusy(true);
|
|
115
|
+
let response;
|
|
116
|
+
let body = null;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetch("/auth/login", {
|
|
119
|
+
method: "POST", credentials: "same-origin", cache: "no-store",
|
|
120
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
121
|
+
body: JSON.stringify({ password: password.value, next: next })
|
|
122
|
+
});
|
|
123
|
+
try { body = await response.json(); } catch {}
|
|
124
|
+
} catch {
|
|
125
|
+
setBusy(false);
|
|
126
|
+
showError("login.error.network");
|
|
127
|
+
password.focus();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (response.ok && body && typeof body.location === "string" && body.location.startsWith("/") && !body.location.startsWith("//")) {
|
|
131
|
+
// Keep the busy state while the browser navigates to the destination.
|
|
132
|
+
window.location.replace(body.location + window.location.hash);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
setBusy(false);
|
|
136
|
+
if (response.status === 429) {
|
|
137
|
+
lockFor(Math.max(1, Number(body && body.retryAfterSeconds) || Number(response.headers.get("retry-after")) || 30));
|
|
138
|
+
} else if (response.status === 401) {
|
|
139
|
+
password.value = "";
|
|
140
|
+
showError("login.error.invalid");
|
|
141
|
+
} else if (response.status === 403) {
|
|
142
|
+
showError("login.error.origin");
|
|
143
|
+
} else if (response.status === 404 && body && body.code === "password-not-configured") {
|
|
144
|
+
window.location.replace("/");
|
|
145
|
+
return;
|
|
146
|
+
} else {
|
|
147
|
+
showError("login.error.network");
|
|
148
|
+
}
|
|
149
|
+
password.focus();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
i18n.subscribe(renderStatic);
|
|
153
|
+
transport.hidden = window.location.protocol !== "http:" || isLoopbackHost(window.location.hostname);
|
|
154
|
+
renderStatic();
|
|
155
|
+
password.focus();
|
|
156
|
+
`;
|
|
@@ -1226,5 +1226,22 @@ export const EN = {
|
|
|
1226
1226
|
"discussion.older": "Show {count} earlier messages",
|
|
1227
1227
|
"discussion.olderLoading": "Reading earlier messages…",
|
|
1228
1228
|
"record.raw": "Raw record",
|
|
1229
|
-
"record.reading": "Reading the exact record…"
|
|
1229
|
+
"record.reading": "Reading the exact record…",
|
|
1230
|
+
"login.title": "Sign in · Yui",
|
|
1231
|
+
"login.heading": "Sign in to Yui",
|
|
1232
|
+
"login.subtitle": "Enter the Web access password from this listener's private credential file.",
|
|
1233
|
+
"login.password": "Password",
|
|
1234
|
+
"login.show": "Show password",
|
|
1235
|
+
"login.hide": "Hide password",
|
|
1236
|
+
"login.submit": "Sign in",
|
|
1237
|
+
"login.submitting": "Signing in…",
|
|
1238
|
+
"login.error.empty": "Enter the password.",
|
|
1239
|
+
"login.error.invalid": "Incorrect password. Check the credential file and try again.",
|
|
1240
|
+
"login.error.throttled": "Too many failed attempts. Try again in {seconds} s.",
|
|
1241
|
+
"login.error.origin": "This address is not accepted by the Web listener. Open one of the URLs printed by yui web.",
|
|
1242
|
+
"login.error.network": "Cannot reach Yui Web. Check that the listener is running, then try again.",
|
|
1243
|
+
"login.notice.expired": "Your session has expired. Sign in again to continue.",
|
|
1244
|
+
"login.notice.signedOut": "You have signed out.",
|
|
1245
|
+
"login.httpWarning": "This connection uses plain HTTP and is not encrypted. Sign in only on a trusted network.",
|
|
1246
|
+
"login.noscript": "JavaScript is required to sign in."
|
|
1230
1247
|
};
|
|
@@ -1222,5 +1222,22 @@ export const ZH_CN = {
|
|
|
1222
1222
|
"discussion.older": "显示更早的 {count} 条消息",
|
|
1223
1223
|
"discussion.olderLoading": "正在读取更早的消息…",
|
|
1224
1224
|
"record.raw": "原始记录",
|
|
1225
|
-
"record.reading": "正在读取完整记录…"
|
|
1225
|
+
"record.reading": "正在读取完整记录…",
|
|
1226
|
+
"login.title": "登录 · Yui",
|
|
1227
|
+
"login.heading": "登录 Yui",
|
|
1228
|
+
"login.subtitle": "输入此 Web 监听所用私有凭证文件中的访问密码。",
|
|
1229
|
+
"login.password": "密码",
|
|
1230
|
+
"login.show": "显示密码",
|
|
1231
|
+
"login.hide": "隐藏密码",
|
|
1232
|
+
"login.submit": "登录",
|
|
1233
|
+
"login.submitting": "正在登录…",
|
|
1234
|
+
"login.error.empty": "请输入密码。",
|
|
1235
|
+
"login.error.invalid": "密码不正确。请核对凭证文件后重试。",
|
|
1236
|
+
"login.error.throttled": "失败次数过多,请在 {seconds} 秒后重试。",
|
|
1237
|
+
"login.error.origin": "Web 监听不接受当前地址。请打开 yui web 输出的访问地址。",
|
|
1238
|
+
"login.error.network": "无法连接 Yui Web。请确认监听仍在运行后重试。",
|
|
1239
|
+
"login.notice.expired": "会话已过期,请重新登录以继续。",
|
|
1240
|
+
"login.notice.signedOut": "你已退出登录。",
|
|
1241
|
+
"login.httpWarning": "当前连接为未加密的 HTTP。请仅在可信网络中登录。",
|
|
1242
|
+
"login.noscript": "需要启用 JavaScript 才能登录。"
|
|
1226
1243
|
};
|
|
@@ -17,6 +17,7 @@ import { FORMAT_SCRIPT } from "./lib/format.js";
|
|
|
17
17
|
import { I18N_SCRIPT } from "./lib/i18n.js";
|
|
18
18
|
import { MARKDOWN_SCRIPT } from "./lib/markdown.js";
|
|
19
19
|
import { PREFS_SCRIPT } from "./lib/prefs.js";
|
|
20
|
+
import { SESSION_SCRIPT } from "./lib/session.js";
|
|
20
21
|
import { THEME_SCRIPT } from "./lib/theme.js";
|
|
21
22
|
import { CONTAINERS_SCRIPT } from "./ui/containers.js";
|
|
22
23
|
import { CONTROLS_SCRIPT } from "./ui/controls.js";
|
|
@@ -66,6 +67,7 @@ import { MODELS_VIEW_SCRIPT } from "./views/models.js";
|
|
|
66
67
|
import { MODELS_SCRIPT } from "./app/models.js";
|
|
67
68
|
import { MANAGE_SHELL_SCRIPT } from "./layout/manageShell.js";
|
|
68
69
|
import { MANAGE_SCRIPT } from "./app/manage.js";
|
|
70
|
+
import { LOGIN_SCRIPT } from "./app/login.js";
|
|
69
71
|
export { APP_SCRIPT } from "./app/main.js";
|
|
70
72
|
export const CLIENT_MODULES = Object.freeze({
|
|
71
73
|
"views/skills": SKILLS_SCRIPT,
|
|
@@ -78,6 +80,7 @@ export const CLIENT_MODULES = Object.freeze({
|
|
|
78
80
|
"lib/i18n": I18N_SCRIPT,
|
|
79
81
|
"lib/theme": THEME_SCRIPT,
|
|
80
82
|
"lib/api": API_SCRIPT,
|
|
83
|
+
"lib/session": SESSION_SCRIPT,
|
|
81
84
|
"lib/markdown": MARKDOWN_SCRIPT,
|
|
82
85
|
"ui/primitives": PRIMITIVES_SCRIPT,
|
|
83
86
|
"ui/containers": CONTAINERS_SCRIPT,
|
|
@@ -121,6 +124,7 @@ export const CLIENT_MODULES = Object.freeze({
|
|
|
121
124
|
"app/taskView": TASK_VIEW_SCRIPT,
|
|
122
125
|
"app/settings": SETTINGS_SCRIPT,
|
|
123
126
|
"app/manage": MANAGE_SCRIPT,
|
|
127
|
+
"app/login": LOGIN_SCRIPT,
|
|
124
128
|
"app/models": MODELS_SCRIPT,
|
|
125
129
|
"app/selection": SELECTION_SCRIPT
|
|
126
130
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const API_SCRIPT = String.raw `
|
|
2
2
|
// Loopback API client. Every request carries the page token; a write is keyed
|
|
3
3
|
// so an unresolved submission can never be silently replayed from this page.
|
|
4
|
+
import { sessionExpired } from "/assets/js/lib/session.js";
|
|
5
|
+
|
|
4
6
|
const token = document.querySelector('meta[name="yui-web-token"]').content;
|
|
5
7
|
const unresolved = new Set();
|
|
6
8
|
|
|
@@ -22,6 +24,9 @@ export async function requestJson(path, options) {
|
|
|
22
24
|
const body = await response.json();
|
|
23
25
|
if (body && body.error) message = body.error;
|
|
24
26
|
if (body && body.disposition === "not-submitted") disposition = "not-submitted";
|
|
27
|
+
// The server rejected the request before reading it: the browser session
|
|
28
|
+
// is missing or expired. Return to the sign-in page for this view.
|
|
29
|
+
if (response.status === 401 && body && body.code === "authentication-required") sessionExpired();
|
|
25
30
|
} catch {}
|
|
26
31
|
throw Object.assign(new Error(message), { disposition, status: response.status });
|
|
27
32
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const SESSION_SCRIPT = String.raw `
|
|
2
|
+
// Browser session helper for password-protected listeners. The session
|
|
3
|
+
// itself is an HttpOnly cookie owned by the server; this module never reads
|
|
4
|
+
// or stores a credential. It only routes an expired page back to the
|
|
5
|
+
// sign-in page with a same-origin return path.
|
|
6
|
+
let redirecting = false;
|
|
7
|
+
|
|
8
|
+
export function returnTarget() {
|
|
9
|
+
return window.location.pathname + window.location.search;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Called when any API read/write answers 401. Navigates once; the fragment
|
|
13
|
+
// survives on the sign-in URL and is restored after a successful sign-in.
|
|
14
|
+
export function sessionExpired() {
|
|
15
|
+
if (redirecting) return;
|
|
16
|
+
redirecting = true;
|
|
17
|
+
const query = new URLSearchParams({ reason: "expired" });
|
|
18
|
+
if (returnTarget() !== "/") query.set("next", returnTarget());
|
|
19
|
+
window.location.assign("/login?" + query.toString() + window.location.hash);
|
|
20
|
+
}
|
|
21
|
+
`;
|
|
@@ -8,6 +8,7 @@ import { h, icon, clear } from "/assets/js/lib/dom.js";
|
|
|
8
8
|
import { fill } from "/assets/js/lib/format.js";
|
|
9
9
|
import { pageToken, requestJson } from "/assets/js/lib/api.js";
|
|
10
10
|
import { dot } from "/assets/js/ui/primitives.js";
|
|
11
|
+
import { sessionExpired } from "/assets/js/lib/session.js";
|
|
11
12
|
|
|
12
13
|
function cssVar(name, fallback) {
|
|
13
14
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
@@ -172,9 +173,11 @@ function bindSocket(ctl, socket, terminal, link, generation) {
|
|
|
172
173
|
terminal.writeln("\r\n" + message.message);
|
|
173
174
|
}
|
|
174
175
|
});
|
|
175
|
-
socket.addEventListener("close", function () {
|
|
176
|
+
socket.addEventListener("close", function (event) {
|
|
176
177
|
if (generation !== ctl.generation) return;
|
|
177
178
|
link.writable = false;
|
|
179
|
+
// 1008: the server closed this terminal because its browser session ended.
|
|
180
|
+
if (event && event.code === 1008) { sessionExpired(); return; }
|
|
178
181
|
if (ctl.stateKey !== "terminal.error") setState(ctl, "terminal.closed", "closed");
|
|
179
182
|
});
|
|
180
183
|
socket.addEventListener("error", function () {
|
|
@@ -32,6 +32,7 @@ export const ICON_PATHS = Object.freeze({
|
|
|
32
32
|
dots: ["M5 12h.01", "M12 12h.01", "M19 12h.01"],
|
|
33
33
|
inbox: ["M4 13l2.5-8h11L20 13", "M4 13v6h16v-6", "M4 13h5l1 2h4l1-2h5"],
|
|
34
34
|
user: ["M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "M4 20a8 8 0 0 1 16 0"],
|
|
35
|
+
eyeOff: ["M3 3l18 18", "M10.6 5.1A10 10 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3 3.9", "M6.6 6.6C3.9 8.3 2 12 2 12s3.6 7 10 7a9.6 9.6 0 0 0 5.4-1.6", "M9.9 9.9a3 3 0 0 0 4.2 4.2"],
|
|
35
36
|
eye: ["M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
|
|
36
37
|
plus: ["M12 5v14", "M5 12h14"],
|
|
37
38
|
play: ["M8 5v14l11-7z"],
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DEFAULT_DARK_THEME } from "../shared/themes.js";
|
|
2
|
+
import { iconSvg } from "../shared/icons.js";
|
|
3
|
+
// The only document served without a session. It carries no page token and
|
|
4
|
+
// no Task data; the client module posts the password as same-origin JSON and
|
|
5
|
+
// the server answers with a session cookie. CSP form-action 'none' means the
|
|
6
|
+
// form can never submit natively (so a password cannot end up in a URL).
|
|
7
|
+
const SHEETS = ["fonts", "tokens", "base", "ui/controls", "ui/forms", "views/login"];
|
|
8
|
+
export const LOGIN_HTML = `<!doctype html>
|
|
9
|
+
<html lang="en" data-theme="${DEFAULT_DARK_THEME.id}">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="utf-8">
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
13
|
+
<meta name="color-scheme" content="dark light">
|
|
14
|
+
<script src="/assets/theme.js"></script>
|
|
15
|
+
<link rel="icon" href="data:,">
|
|
16
|
+
<title>Sign in · Yui</title>
|
|
17
|
+
<link rel="preload" href="/assets/fonts/inter-500.woff2" as="font" type="font/woff2" crossorigin>
|
|
18
|
+
<link rel="preload" href="/assets/fonts/inter-600.woff2" as="font" type="font/woff2" crossorigin>
|
|
19
|
+
${SHEETS.map(name => ` <link rel="stylesheet" href="/assets/css/${name}.css">`).join("\n")}
|
|
20
|
+
</head>
|
|
21
|
+
<body class="login-body">
|
|
22
|
+
<main class="login-shell">
|
|
23
|
+
<div class="login-top">
|
|
24
|
+
<label class="login-locale"><span class="sr-only" data-i18n="settings.language">Language</span><select id="locale-select"><option value="en">English</option><option value="zh-CN">简体中文</option></select></label>
|
|
25
|
+
</div>
|
|
26
|
+
<section class="login-card" aria-labelledby="login-title">
|
|
27
|
+
<div class="login-brand">
|
|
28
|
+
<span class="brand-mark" aria-hidden="true">結</span>
|
|
29
|
+
<span class="brand-name">Yui</span>
|
|
30
|
+
</div>
|
|
31
|
+
<header class="login-heading">
|
|
32
|
+
<h1 id="login-title" data-i18n="login.heading">Sign in to Yui</h1>
|
|
33
|
+
<p id="login-subtitle" class="login-sub" data-i18n="login.subtitle">Enter the Web access password from this listener's private credential file.</p>
|
|
34
|
+
</header>
|
|
35
|
+
<p id="login-notice" class="login-notice" role="status" hidden></p>
|
|
36
|
+
<form id="login-form" class="login-form" method="post" novalidate aria-describedby="login-subtitle">
|
|
37
|
+
<input class="sr-only" type="text" name="username" value="yui" autocomplete="username" tabindex="-1" aria-hidden="true" readonly>
|
|
38
|
+
<div class="login-field">
|
|
39
|
+
<label class="login-label" for="login-password" data-i18n="login.password">Password</label>
|
|
40
|
+
<div class="login-password">
|
|
41
|
+
<input id="login-password" name="password" type="password" autocomplete="current-password" required maxlength="256" spellcheck="false" autocapitalize="off" autocorrect="off" aria-describedby="login-error" aria-invalid="false" autofocus>
|
|
42
|
+
<button id="login-reveal" class="icon-btn login-reveal" type="button" aria-controls="login-password" aria-pressed="false" aria-label="Show password" data-i18n-aria-label="login.show" title="Show password" data-i18n-title="login.show">${iconSvg("eye", "icon icon-show")}${iconSvg("eyeOff", "icon icon-hide")}</button>
|
|
43
|
+
</div>
|
|
44
|
+
<p id="login-error" class="login-error" role="alert" hidden></p>
|
|
45
|
+
</div>
|
|
46
|
+
<button id="login-submit" class="btn btn-primary login-submit" type="submit" disabled>
|
|
47
|
+
<span class="login-spinner" aria-hidden="true"></span><span id="login-submit-label" data-i18n="login.submit">Sign in</span>
|
|
48
|
+
</button>
|
|
49
|
+
</form>
|
|
50
|
+
<p id="login-transport" class="login-transport" hidden>${iconSvg("alert")}<span data-i18n="login.httpWarning">This connection uses plain HTTP and is not encrypted. Sign in only on a trusted network.</span></p>
|
|
51
|
+
<noscript><p class="login-error" data-i18n="login.noscript">JavaScript is required to sign in.</p></noscript>
|
|
52
|
+
</section>
|
|
53
|
+
</main>
|
|
54
|
+
<script type="module" src="/assets/js/app/login.js"></script>
|
|
55
|
+
</body>
|
|
56
|
+
</html>`;
|
|
@@ -19,6 +19,7 @@ import { MANAGE_STYLES } from "./views/manage.js";
|
|
|
19
19
|
import { SETTINGS_STYLES } from "./views/settings.js";
|
|
20
20
|
import { MODELS_STYLES } from "./views/models.js";
|
|
21
21
|
import { JOBS_STYLES } from "./views/jobs.js";
|
|
22
|
+
import { LOGIN_STYLES } from "./views/login.js";
|
|
22
23
|
// The stylesheet registry, in cascade order: foundations (fonts, tokens,
|
|
23
24
|
// element defaults), the workspace frame, shared UI primitives, then one
|
|
24
25
|
// sheet per view, rendered prose, and breakpoints last so they override
|
|
@@ -44,6 +45,7 @@ export const STYLESHEETS = Object.freeze([
|
|
|
44
45
|
{ name: "views/settings", body: SETTINGS_STYLES },
|
|
45
46
|
{ name: "views/models", body: MODELS_STYLES },
|
|
46
47
|
{ name: "views/jobs", body: JOBS_STYLES },
|
|
48
|
+
{ name: "views/login", body: LOGIN_STYLES },
|
|
47
49
|
{ name: "markdown", body: MARKDOWN_STYLES },
|
|
48
50
|
{ name: "layout/responsive", body: RESPONSIVE_STYLES }
|
|
49
51
|
]);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* LOGIN VIEW — the standalone sign-in document: one centred card on the
|
|
3
|
+
* canvas with the workbench brand, a password field (with reveal toggle),
|
|
4
|
+
* inline error/notice states and the plain-HTTP reminder. Colour comes only
|
|
5
|
+
* from theme tokens; the card collapses to the full width on narrow screens.
|
|
6
|
+
*/
|
|
7
|
+
export const LOGIN_STYLES = `
|
|
8
|
+
.login-body{height:auto;min-height:100vh;min-height:100dvh;overflow:auto}
|
|
9
|
+
.login-shell{position:relative;display:grid;place-items:center;min-height:100vh;min-height:100dvh;padding:72px 16px 48px;background:radial-gradient(120% 70% at 50% -10%,var(--accent-soft),transparent 60%),var(--canvas)}
|
|
10
|
+
.login-top{position:absolute;top:16px;right:16px}
|
|
11
|
+
.login-locale select{height:30px;width:auto;max-width:132px;padding:0 26px 0 9px;line-height:28px;font-size:var(--fs-xs);background-color:var(--surface)}
|
|
12
|
+
.login-card{display:grid;gap:20px;width:min(400px,100%);padding:32px 28px 28px;border:1px solid var(--line-2);border-radius:var(--r-lg);background:var(--surface);box-shadow:var(--shadow-2)}
|
|
13
|
+
.login-brand{display:flex;align-items:center;gap:10px}
|
|
14
|
+
.login-brand .brand-mark{display:grid;place-items:center;width:32px;height:32px;flex:none;border-radius:9px;background:var(--accent);color:var(--accent-ink);font-size:17px;font-weight:700;line-height:1}
|
|
15
|
+
.login-brand .brand-name{font-size:var(--fs-lg);font-weight:650;letter-spacing:-.02em}
|
|
16
|
+
.login-heading{display:grid;gap:6px}
|
|
17
|
+
.login-heading h1{font-size:var(--fs-2xl)}
|
|
18
|
+
.login-sub{color:var(--ink-3);font-size:var(--fs-sm);overflow-wrap:anywhere}
|
|
19
|
+
.login-notice{margin:0;padding:10px 12px;border:1px solid var(--line-2);border-left:3px solid var(--tone,var(--info));border-radius:var(--r-sm);background:var(--tone-soft,var(--info-soft));color:var(--ink);font-size:var(--fs-sm)}
|
|
20
|
+
.login-form{display:grid;gap:16px}
|
|
21
|
+
.login-field{display:grid;gap:6px;min-width:0}
|
|
22
|
+
.login-label{color:var(--ink-2);font-size:var(--fs-xs);font-weight:500}
|
|
23
|
+
.login-password{position:relative}
|
|
24
|
+
.login-password input{height:40px;padding-right:44px}
|
|
25
|
+
.login-password input[aria-invalid="true"]{border-color:var(--bad)}
|
|
26
|
+
.login-password input[aria-invalid="true"]:focus{box-shadow:0 0 0 3px var(--bad-soft)}
|
|
27
|
+
.login-reveal{position:absolute;top:4px;right:4px}
|
|
28
|
+
.login-reveal .icon-hide,.login-reveal[aria-pressed="true"] .icon-show{display:none}
|
|
29
|
+
.login-reveal[aria-pressed="true"] .icon-hide{display:block}
|
|
30
|
+
.login-error{margin:0;color:var(--bad);font-size:var(--fs-sm);overflow-wrap:anywhere}
|
|
31
|
+
.login-submit{width:100%;height:40px;font-size:var(--fs-md)}
|
|
32
|
+
.login-spinner{display:none;width:14px;height:14px;flex:none;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:login-spin .8s linear infinite}
|
|
33
|
+
.login-form[aria-busy="true"] .login-spinner{display:inline-block}
|
|
34
|
+
@keyframes login-spin{to{transform:rotate(360deg)}}
|
|
35
|
+
.login-transport{display:flex;align-items:flex-start;gap:8px;margin:0;padding-top:16px;border-top:1px solid var(--line);color:var(--ink-3);font-size:var(--fs-xs)}
|
|
36
|
+
.login-transport .icon{width:14px;height:14px;margin-top:2px;color:var(--warn)}
|
|
37
|
+
@media(max-width:480px){
|
|
38
|
+
.login-shell{place-items:start center;padding:64px 12px 32px}
|
|
39
|
+
.login-card{padding:24px 20px;border-radius:var(--r-md)}
|
|
40
|
+
.login-heading h1{font-size:var(--fs-xl)}
|
|
41
|
+
}
|
|
42
|
+
`;
|