@fonlan/dsh-web-auth 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -6,11 +6,11 @@
6
6
 
7
7
  - **登录页 + HttpOnly 签名 Cookie**(HMAC-SHA256,7 天滑动续期),浏览器自动携带,HTTP / WebSocket / SSE / 静态资源全覆盖
8
8
  - **首次设置**:未配置密码时,登录页显示「设置访问密码」表单(引导流程,见下文安全说明)
9
- - **GUI 改密**:设置 → 插件配置 →「访问认证」卡片,需旧密码;改密后**轮换签名密钥,所有已登录会话立即下线**
9
+ - **GUI 改密**:设置 → 插件 →「访问认证」卡片(插件配置 tab),需旧密码;改密后**轮换签名密钥,所有已登录会话立即下线**
10
10
  - **退出登录**:设置卡片按钮,或访问 `/logout`
11
11
  - **防爆破**:登录与改密接口按客户端 IP 限速(连续 5 次失败锁 1 分钟起,指数退避至 30 分钟;15 分钟内累计)
12
12
  - **反代友好**:Cookie `Secure` 自动跟随 `x-forwarded-proto`;限速信任回环对端的 `X-Forwarded-For`
13
- - **监听地址切换**:设置卡片可一键把 dsh 的监听地址在 `127.0.0.1`(仅本机)与 `0.0.0.0`(所有网卡 / 局域网可访问)之间切换,热生效、无需重启进程(写入 patch 层由 HMR 应用,webserver 自动重新绑定)
13
+ - **监听地址切换**:设置卡片可一键把 dsh 的监听地址在 `127.0.0.1`(仅本机)与 `0.0.0.0`(所有网卡 / 局域网可访问)之间切换,热生效、无需重启进程(写入 `web-auth` settings 命名空间 → host 半区应用 patch → HMR 重绑 webserver)
14
14
  - 密码以 **scrypt 加盐哈希**存储(`$DSH_HOME/web-auth/password.hash`,0600),永不回显、不落明文配置
15
15
 
16
16
  ## 安装
@@ -34,7 +34,7 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
34
34
  1. 未设置过密码时,登录页显示「设置访问密码」——输入 **≥8 位**的新密码并确认
35
35
  2. 设置成功后立即签发会话并跳回原页面;此后每次访问都要输密码
36
36
 
37
- 改密入口在 **设置 → 插件配置 → 访问认证**(需先登录)。
37
+ 改密入口在 **设置 → 插件 → 插件配置 → 访问认证**(需先登录)。
38
38
 
39
39
  ## 行为细节
40
40
 
@@ -49,15 +49,15 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
49
49
  | 连续失败 | 按 IP 限速,提示「尝试过于频繁」 |
50
50
  | 会话过期 | 跳回登录页;滑动续期:剩余不足 24h 自动刷新 |
51
51
  | 修改密码 | 校验旧密码 → 轮换签名密钥 → **全员下线**,跳转登录页 |
52
- | 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写 patch 层(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
52
+ | 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写入 `web-auth` settings 命名空间(revision 设栅)→ host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
53
53
  | `/logout` | 清除 Cookie,跳转登录页 |
54
54
 
55
55
  ### 切换监听地址
56
56
 
57
- dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
57
+ dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
58
58
 
59
- - 切换写入当前部署的 patch 层:profile 的 `cordis.patch.yml`;若 home 层(`$DSH_HOME/cordis.patch.yml`)已声明 webserver 行则写 home 层(home 优先级更高,改在低层会被覆盖)。文件里其余内容(注释、`!!js` 端口表达式)原样保留。
60
- - dsh 的 HMR 会监听到 patch 文件变化并热重载 webserver 行:**关闭旧监听、按新地址重新绑定**,全程无需重启进程。重启进程后该设置依然生效(它就在 patch 文件里)。
59
+ - 切换写入 `web-auth` settings 命名空间(`$DSH_HOME/settings.yaml`,revision 设栅);host 半区监听到提交后应用到当前部署的 patch 层:profile 的 `cordis.patch.yml`;若 home 层(`$DSH_HOME/cordis.patch.yml`)已声明 webserver 行则写 home 层(home 优先级更高,改在低层会被覆盖)。文件里其余内容(注释、`!!js` 端口表达式)原样保留。
60
+ - dsh 的 HMR 会监听到 patch 文件变化并热重载 webserver 行:**关闭旧监听、按新地址重新绑定**,全程无需重启进程。重启进程后该设置依然生效(settings 命名空间 + patch 文件都在)。
61
61
  - 切换瞬间所有连接(包括当前页面与 WebSocket)会断开重连,属正常现象;卡片会轮询状态确认新地址生效。
62
62
  - 注意:若你正通过**局域网 IP**(`0.0.0.0` 模式)访问并切回 `127.0.0.1`,切回后只能在本机访问,请改用 `127.0.0.1` 重新打开页面。
63
63
 
@@ -86,8 +86,9 @@ npm run test # 32 项单元 + 集成测试(node:test,真实 HTTP/WS
86
86
  - `src/gate.ts` — 把认证闸门包到 node:http 服务器的 request/upgrade 监听器外层
87
87
  - `src/handlers.ts` — 登录页/登录/登出/改密/状态/监听切换 路由处理
88
88
  - `src/profile-patch.ts` — 监听地址切换:webserver 行的 patch 层文本改写(保留注释与 `!!js` 表达式)
89
+ - `src/settings.ts` — `web-auth` settings 命名空间注册 + listenHost 变更应用(卡片的分发键与数据源)
89
90
  - `src/index.ts` — cordis 插件装配(host 半区)
90
- - `src/client/index.tsx` — 设置卡片(浏览器半区,settings.section 插槽)
91
+ - `src/client/index.tsx` — 设置卡片(浏览器半区,`settings.plugin.item` 插槽,keyed by `web-auth` 命名空间)
91
92
  - `cordis.patch.yml` — bundle 补丁层(insert 插件行)
92
93
 
93
94
  ## License
package/cordis.patch.yml CHANGED
@@ -11,7 +11,10 @@
11
11
  # the auth gate wraps the HTTP listener after it is bound.
12
12
  #
13
13
  # The package also declares `dsh.client` (see package.json), so the web app
14
- # loads the browser half (the settings section card) automatically.
14
+ # loads the browser half (the plugin's own Settings Card) automatically. The
15
+ # card registers on the `settings.plugin.item` slot keyed by the `web-auth`
16
+ # settings namespace (see src/settings.ts) — the 设置 → 插件 → 插件配置 tab
17
+ # pairs the two.
15
18
  #
16
19
  # There is intentionally NO password here: passwords are maintained through
17
20
  # the GUI (login page first setup / settings card change), stored hashed
@@ -1,8 +1,14 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
- /** Services provided by other browser plugins before this one mounts. */
2
+ /** Services provided by other browser plugins before this one mounts.
3
+ *
4
+ * `connection` and `remote` are required by `settingsScope.bind` (it resolves
5
+ * the settings transport and the forwarded-invalidation subscription through
6
+ * `ctx.get`), so the fiber must wait for them — exactly like
7
+ * dsh-client-ui-settings-plugins does for its own cards.
8
+ */
3
9
  export declare const inject: string[];
4
10
  /**
5
- * Client plugin body: register the settings section. The shell's declaration
11
+ * Client plugin body: register the settings card. The shell's declaration
6
12
  * may not be on the ledger yet, so the registration waits via slots.inject.
7
13
  * @param ctx - the client cordis context.
8
14
  */
package/lib/client.js CHANGED
@@ -37,9 +37,36 @@ var dshWebAuthModule = (() => {
37
37
  });
38
38
  var import_react = __require("react");
39
39
  var import_jsx_runtime = __require("react/jsx-runtime");
40
- var inject = ["slots"];
40
+ var inject = ["slots", "settingsScope", "connection", "remote"];
41
+ var WEB_AUTH_NS = "web-auth";
41
42
  var CSS = `
42
- .dwa-card { display: flex; flex-direction: column; gap: 12px; padding: 16px; }
43
+ .dwa-card {
44
+ border: 1px solid var(--dsw-alias-border-l2, #2a3346);
45
+ background: var(--dsw-alias-bg-layer-3, #0d1017);
46
+ border-radius: 12px;
47
+ list-style: none;
48
+ transition: border-color .16s, background .16s;
49
+ }
50
+ .dwa-card:hover { border-color: var(--dsw-alias-label-dimmed, #4a5468); }
51
+ .dwa-card[data-open] {
52
+ background: var(--dsw-alias-bg-layer-2, #141a26);
53
+ border-color: var(--dsw-alias-label-dimmed, #4a5468);
54
+ }
55
+ .dwa-head {
56
+ appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer;
57
+ background: transparent; border: 0; border-radius: 12px;
58
+ align-items: center; gap: 12px; padding: 14px 16px; display: flex;
59
+ }
60
+ .dwa-head:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #4f8cff); outline-offset: -2px; }
61
+ .dwa-headText { flex-direction: column; flex: 1; gap: 4px; min-width: 0; display: flex; }
62
+ .dwa-title { color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 15px; font-weight: 600; line-height: 1.4; }
63
+ .dwa-sub { color: var(--dsw-alias-label-tertiary, #8b93a7); font-size: 13px; line-height: 1.5; }
64
+ .dwa-chevron {
65
+ color: var(--dsw-alias-label-tertiary, #8b93a7); flex: none;
66
+ transition: transform .16s; display: flex; align-items: center; justify-content: center;
67
+ }
68
+ .dwa-chevron[data-open] { transform: rotate(180deg); }
69
+ .dwa-body { border-top: 1px solid var(--dsw-alias-border-l2, #2a3346); margin: 0 16px; padding: 12px 0 8px; display: flex; flex-direction: column; gap: 12px; }
43
70
  .dwa-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
44
71
  .dwa-status { font-size: 13px; line-height: 1.6; }
45
72
  .dwa-status[data-on] { color: var(--dsw-alias-label-primary, #e6e6e6); }
@@ -104,33 +131,48 @@ var dshWebAuthModule = (() => {
104
131
  function isListenHost(value) {
105
132
  return value === "127.0.0.1" || value === "0.0.0.0";
106
133
  }
107
- function PasswordSection(_props) {
134
+ async function fetchStatus() {
135
+ try {
136
+ const response = await fetch("/api/web-auth/status");
137
+ if (!response.ok) return null;
138
+ const data = await response.json();
139
+ return { configured: data.configured === true, host: isListenHost(data.host) ? data.host : void 0 };
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+ function AuthCard(props) {
145
+ const { scope } = props;
146
+ const snapshot = (0, import_react.useSyncExternalStore)(
147
+ (listener) => scope.subscribe(listener),
148
+ () => scope.getSnapshot()
149
+ );
108
150
  const [configured, setConfigured] = (0, import_react.useState)(null);
151
+ const [host, setHost] = (0, import_react.useState)(null);
109
152
  const [oldPassword, setOldPassword] = (0, import_react.useState)("");
110
153
  const [newPassword, setNewPassword] = (0, import_react.useState)("");
111
154
  const [confirm, setConfirm] = (0, import_react.useState)("");
112
155
  const [busy, setBusy] = (0, import_react.useState)(false);
113
156
  const [message, setMessage] = (0, import_react.useState)(null);
114
- const [listenHost, setListenHost] = (0, import_react.useState)(null);
115
157
  const [listenPick, setListenPick] = (0, import_react.useState)("127.0.0.1");
116
158
  const [listenBusy, setListenBusy] = (0, import_react.useState)(false);
117
159
  const [listenMsg, setListenMsg] = (0, import_react.useState)(null);
160
+ const [open, setOpen] = (0, import_react.useState)(false);
118
161
  (0, import_react.useEffect)(() => {
119
162
  let alive = true;
120
- void fetch("/api/web-auth/status").then((response) => response.ok ? response.json() : null).then((data) => {
121
- if (!alive) return;
122
- setConfigured(data?.configured === true);
123
- if (isListenHost(data?.host)) {
124
- setListenHost(data.host);
125
- setListenPick(data.host);
163
+ void fetchStatus().then((status) => {
164
+ if (!alive || status === null) return;
165
+ setConfigured(status.configured);
166
+ if (status.host !== void 0) {
167
+ setHost(status.host);
168
+ setListenPick(status.host);
126
169
  }
127
- }).catch(() => {
128
- if (alive) setConfigured(null);
129
170
  });
130
171
  return () => {
131
172
  alive = false;
132
173
  };
133
174
  }, []);
175
+ if (snapshot.status === "unavailable") return null;
134
176
  const submit = async () => {
135
177
  if (newPassword.length < 8) {
136
178
  setMessage({ kind: "err", text: "\u65B0\u5BC6\u7801\u81F3\u5C11 8 \u4F4D\u3002" });
@@ -156,144 +198,150 @@ var dshWebAuthModule = (() => {
156
198
  }
157
199
  };
158
200
  const applyListen = async () => {
159
- if (listenHost === listenPick) return;
201
+ if (host === listenPick) return;
160
202
  setListenBusy(true);
161
203
  setListenMsg(null);
162
204
  try {
163
- const response = await fetch("/api/web-auth/listen", {
164
- method: "POST",
165
- headers: { "content-type": "application/json" },
166
- body: JSON.stringify({ host: listenPick })
167
- });
168
- const data = await response.json();
169
- if (response.ok && data.ok === true) {
170
- if (data.applying === true) {
171
- setListenMsg({ kind: "ok", text: "\u5DF2\u5207\u6362\uFF0C\u6B63\u5728\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF0C\u9875\u9762\u5C06\u77ED\u6682\u65AD\u8FDE\u2026" });
172
- for (let i = 0; i < 15; i++) {
173
- await new Promise((resolve) => setTimeout(resolve, 600));
174
- try {
175
- const check = await fetch("/api/web-auth/status");
176
- const status = await check.json();
177
- if (isListenHost(status.host) && status.host === listenPick) {
178
- setListenHost(listenPick);
179
- setListenMsg({ kind: "ok", text: "\u5DF2\u751F\u6548\uFF1A\u76D1\u542C\u5730\u5740\u4E3A " + listenPick + "\u3002" });
180
- return;
181
- }
182
- } catch {
183
- }
184
- }
185
- setListenMsg({ kind: "ok", text: "\u5DF2\u5E94\u7528\uFF0C\u82E5\u9875\u9762\u8FDE\u63A5\u4E2D\u65AD\u8BF7\u7A0D\u5019\u5237\u65B0\uFF08\u76D1\u542C\u5730\u5740\uFF1A" + listenPick + "\uFF09\u3002" });
186
- } else {
187
- setListenHost(listenPick);
188
- setListenMsg({ kind: "ok", text: "\u76D1\u542C\u5730\u5740\u672A\u53D8\u5316\u3002" });
205
+ await scope.set("listenHost", listenPick);
206
+ setListenMsg({ kind: "ok", text: "\u5DF2\u5207\u6362\uFF0C\u6B63\u5728\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF0C\u9875\u9762\u5C06\u77ED\u6682\u65AD\u8FDE\u2026" });
207
+ for (let i = 0; i < 15; i++) {
208
+ await new Promise((resolve) => setTimeout(resolve, 600));
209
+ const status = await fetchStatus();
210
+ if (status?.host === listenPick) {
211
+ setHost(listenPick);
212
+ setListenMsg({ kind: "ok", text: "\u5DF2\u751F\u6548\uFF1A\u76D1\u542C\u5730\u5740\u4E3A " + listenPick + "\u3002" });
213
+ return;
189
214
  }
190
- } else {
191
- const text = data.error === "invalid-host" ? "\u65E0\u6548\u7684\u76D1\u542C\u5730\u5740\u3002" : data.error === "unsupported" ? "\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301\u4FEE\u6539\u76D1\u542C\u5730\u5740\u3002" : "\u5207\u6362\u5931\u8D25\uFF1A" + String(data.error ?? response.status);
192
- setListenMsg({ kind: "err", text });
193
215
  }
216
+ setListenMsg({ kind: "ok", text: "\u5DF2\u5E94\u7528\uFF0C\u82E5\u9875\u9762\u8FDE\u63A5\u4E2D\u65AD\u8BF7\u7A0D\u5019\u5237\u65B0\uFF08\u76D1\u542C\u5730\u5740\uFF1A" + listenPick + "\uFF09\u3002" });
194
217
  } catch {
195
- setListenMsg({ kind: "err", text: "\u7F51\u7EDC\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002" });
218
+ setListenMsg({ kind: "err", text: "\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002" });
196
219
  } finally {
197
220
  setListenBusy(false);
198
221
  }
199
222
  };
200
- if (configured === null) {
201
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-card", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u52A0\u8F7D\u4E2D\u2026" }) });
202
- }
203
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-card", children: [
204
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-row", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-status", "data-on": configured ? void 0 : "", "data-off": configured ? "" : void 0, children: configured ? "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u5DF2\u542F\u7528" : "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u672A\u8BBE\u7F6E\u5BC6\u7801\uFF08\u5F53\u524D\u672A\u542F\u7528\uFF09" }) }),
205
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u8BBE\u7F6E\u540E\uFF0C\u8BBF\u95EE\u672C\u670D\u52A1\u9700\u8981\u8F93\u5165\u5BC6\u7801\uFF1B\u4F1A\u8BDD\u6709\u6548\u671F 7 \u5929\uFF08\u6D3B\u52A8\u81EA\u52A8\u7EED\u671F\uFF09\u3002\u4FEE\u6539\u5BC6\u7801\u4F1A\u4F7F\u6240\u6709\u5DF2\u767B\u5F55\u4F1A\u8BDD\u7ACB\u5373\u5931\u6548\u3002" }),
206
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
207
- configured && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
208
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-old", children: "\u65E7\u5BC6\u7801" }),
209
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
210
- "input",
211
- {
212
- id: "dwa-old",
213
- className: "dwa-input",
214
- type: "password",
215
- autoComplete: "current-password",
216
- value: oldPassword,
217
- onChange: (e) => setOldPassword(e.target.value)
218
- }
219
- )
220
- ] }),
221
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
222
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-new", children: "\u65B0\u5BC6\u7801\uFF08\u81F3\u5C11 8 \u4F4D\uFF09" }),
223
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
224
- "input",
225
- {
226
- id: "dwa-new",
227
- className: "dwa-input",
228
- type: "password",
229
- autoComplete: "new-password",
230
- value: newPassword,
231
- onChange: (e) => setNewPassword(e.target.value)
232
- }
233
- )
234
- ] }),
235
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
236
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-confirm", children: "\u786E\u8BA4\u65B0\u5BC6\u7801" }),
237
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
238
- "input",
239
- {
240
- id: "dwa-confirm",
241
- className: "dwa-input",
242
- type: "password",
243
- autoComplete: "new-password",
244
- value: confirm,
245
- onChange: (e) => setConfirm(e.target.value)
246
- }
247
- )
248
- ] }),
249
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-actions", children: [
250
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", disabled: busy, onClick: () => void submit(), children: "\u4FEE\u6539\u5BC6\u7801" }),
251
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", "data-ghost": "", onClick: () => {
252
- window.location.href = "/logout";
253
- }, children: "\u9000\u51FA\u767B\u5F55" })
254
- ] }),
255
- message !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": message.kind, role: "alert", children: message.text }),
256
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
257
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
258
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-listen", children: "\u76D1\u542C\u5730\u5740" }),
259
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
260
- "select",
261
- {
262
- id: "dwa-listen",
263
- className: "dwa-input",
264
- value: listenPick,
265
- disabled: listenBusy || listenHost === null,
266
- onChange: (e) => setListenPick(e.target.value),
267
- children: LISTEN_OPTIONS.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: option.value, children: option.label }, option.value))
268
- }
269
- )
270
- ] }),
271
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-actions", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
223
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-card", "data-open": open ? "" : void 0, children: [
224
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
272
225
  "button",
273
226
  {
274
- className: "dwa-btn",
275
- disabled: listenBusy || listenHost === null || listenHost === listenPick,
276
- onClick: () => void applyListen(),
277
- children: "\u5E94\u7528\u76D1\u542C\u5730\u5740"
227
+ type: "button",
228
+ className: "dwa-head",
229
+ "aria-expanded": open,
230
+ "aria-label": (open ? "\u6536\u8D77" : "\u5C55\u5F00") + "\uFF1A\u8BBF\u95EE\u8BA4\u8BC1",
231
+ onClick: () => setOpen(!open),
232
+ children: [
233
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dwa-headText", children: [
234
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-title", children: "\u8BBF\u95EE\u8BA4\u8BC1" }),
235
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-sub", children: "Web \u8BBF\u95EE\u5BC6\u7801\u4E0E\u76D1\u542C\u5730\u5740" })
236
+ ] }),
237
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-chevron", "data-open": open ? "" : void 0, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "14", height: "14", viewBox: "0 0 14 14", fill: "none", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
238
+ "path",
239
+ {
240
+ d: "M3.5 5.25L7 8.75L10.5 5.25",
241
+ stroke: "currentColor",
242
+ strokeWidth: "1.5",
243
+ strokeLinecap: "round",
244
+ strokeLinejoin: "round"
245
+ }
246
+ ) }) })
247
+ ]
278
248
  }
279
- ) }),
280
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u5207\u6362\u540E dsh \u4F1A\u7ACB\u5373\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF08WebSocket \u77ED\u6682\u65AD\u8FDE\u540E\u81EA\u52A8\u91CD\u8FDE\uFF09\u3002\u6CE8\u610F\uFF1A\u82E5\u5F53\u524D\u901A\u8FC7\u5C40\u57DF\u7F51 IP \u8BBF\u95EE\uFF0C\u5207\u56DE 127.0.0.1 \u540E\u53EA\u80FD\u5728\u672C\u673A\u8BBF\u95EE\u3002" }),
281
- listenMsg !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": listenMsg.kind, role: "alert", children: listenMsg.text })
249
+ ),
250
+ open && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-body", children: [
251
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-row", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-status", "data-on": configured ? void 0 : "", "data-off": configured ? "" : void 0, children: configured === null ? "\u52A0\u8F7D\u4E2D\u2026" : configured ? "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u5DF2\u542F\u7528" : "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u672A\u8BBE\u7F6E\u5BC6\u7801\uFF08\u5F53\u524D\u672A\u542F\u7528\uFF09" }) }),
252
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u8BBE\u7F6E\u540E\uFF0C\u8BBF\u95EE\u672C\u670D\u52A1\u9700\u8981\u8F93\u5165\u5BC6\u7801\uFF1B\u4F1A\u8BDD\u6709\u6548\u671F 7 \u5929\uFF08\u6D3B\u52A8\u81EA\u52A8\u7EED\u671F\uFF09\u3002\u4FEE\u6539\u5BC6\u7801\u4F1A\u4F7F\u6240\u6709\u5DF2\u767B\u5F55\u4F1A\u8BDD\u7ACB\u5373\u5931\u6548\u3002" }),
253
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
254
+ configured && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
255
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-old", children: "\u65E7\u5BC6\u7801" }),
256
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
257
+ "input",
258
+ {
259
+ id: "dwa-old",
260
+ className: "dwa-input",
261
+ type: "password",
262
+ autoComplete: "current-password",
263
+ value: oldPassword,
264
+ onChange: (e) => setOldPassword(e.target.value)
265
+ }
266
+ )
267
+ ] }),
268
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
269
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-new", children: "\u65B0\u5BC6\u7801\uFF08\u81F3\u5C11 8 \u4F4D\uFF09" }),
270
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
271
+ "input",
272
+ {
273
+ id: "dwa-new",
274
+ className: "dwa-input",
275
+ type: "password",
276
+ autoComplete: "new-password",
277
+ value: newPassword,
278
+ onChange: (e) => setNewPassword(e.target.value)
279
+ }
280
+ )
281
+ ] }),
282
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
283
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-confirm", children: "\u786E\u8BA4\u65B0\u5BC6\u7801" }),
284
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
285
+ "input",
286
+ {
287
+ id: "dwa-confirm",
288
+ className: "dwa-input",
289
+ type: "password",
290
+ autoComplete: "new-password",
291
+ value: confirm,
292
+ onChange: (e) => setConfirm(e.target.value)
293
+ }
294
+ )
295
+ ] }),
296
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-actions", children: [
297
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", disabled: busy, onClick: () => void submit(), children: "\u4FEE\u6539\u5BC6\u7801" }),
298
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", "data-ghost": "", onClick: () => {
299
+ window.location.href = "/logout";
300
+ }, children: "\u9000\u51FA\u767B\u5F55" })
301
+ ] }),
302
+ message !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": message.kind, role: "alert", children: message.text }),
303
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
304
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
305
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-listen", children: "\u76D1\u542C\u5730\u5740" }),
306
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
307
+ "select",
308
+ {
309
+ id: "dwa-listen",
310
+ className: "dwa-input",
311
+ value: listenPick,
312
+ disabled: listenBusy || host === null || !snapshot.writable,
313
+ onChange: (e) => setListenPick(e.target.value),
314
+ children: LISTEN_OPTIONS.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: option.value, children: option.label }, option.value))
315
+ }
316
+ )
317
+ ] }),
318
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-actions", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
319
+ "button",
320
+ {
321
+ className: "dwa-btn",
322
+ disabled: listenBusy || host === null || host === listenPick || !snapshot.writable,
323
+ onClick: () => void applyListen(),
324
+ children: "\u5E94\u7528\u76D1\u542C\u5730\u5740"
325
+ }
326
+ ) }),
327
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u5207\u6362\u540E dsh \u4F1A\u7ACB\u5373\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF08WebSocket \u77ED\u6682\u65AD\u8FDE\u540E\u81EA\u52A8\u91CD\u8FDE\uFF09\u3002\u6CE8\u610F\uFF1A\u82E5\u5F53\u524D\u901A\u8FC7\u5C40\u57DF\u7F51 IP \u8BBF\u95EE\uFF0C\u5207\u56DE 127.0.0.1 \u540E\u53EA\u80FD\u5728\u672C\u673A\u8BBF\u95EE\u3002" }),
328
+ listenMsg !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": listenMsg.kind, role: "alert", children: listenMsg.text })
329
+ ] })
282
330
  ] });
283
331
  }
284
332
  function apply(ctx) {
285
333
  injectStyle();
286
- const slots = ctx.slots;
287
- slots.inject(
288
- "settings.section",
289
- () => slots.register(
334
+ const services = ctx;
335
+ const scope = services.settingsScope.bind({ namespace: WEB_AUTH_NS });
336
+ services.slots.inject(
337
+ "settings.plugin.item",
338
+ () => services.slots.register(
290
339
  {
291
- name: "settings.section",
292
- id: "web-auth",
293
- order: 120,
294
- label: () => "\u8BBF\u95EE\u8BA4\u8BC1"
340
+ name: "settings.plugin.item",
341
+ key: WEB_AUTH_NS,
342
+ inject: () => ({ scope })
295
343
  },
296
- PasswordSection
344
+ AuthCard
297
345
  )
298
346
  );
299
347
  }
@@ -34,4 +34,11 @@ export interface PluginContext {
34
34
  * boot anchors on — the loader inherits it down the whole tree).
35
35
  */
36
36
  baseUrl: string;
37
+ /**
38
+ * Attach a callback when listed services are ready (cordis registry,
39
+ * mixed onto every context). Used to attach the settings namespace
40
+ * opportunistically: the callback only runs where a settings service
41
+ * exists (web profiles), so CLI/headless profiles skip it.
42
+ */
43
+ inject(deps: string[], callback: (sctx: PluginContext) => void): unknown;
37
44
  }
package/lib/index.js CHANGED
@@ -17,6 +17,7 @@ import { RateLimiter } from "./auth-core.js";
17
17
  import { readSecret, readPasswordHash, resolveDshHome, rotateSecret, writePasswordHash } from "./state.js";
18
18
  import { createGate, handleChangePassword, handleListenChange, handleLoginPage, handleLoginPost, handleLogout, handleStatus } from "./handlers.js";
19
19
  import { LISTEN_APPLY_DELAY_MS, prepareWebserverHostPatch, resolveListenPatchPath, writeWebserverHostPatch } from "./profile-patch.js";
20
+ import { installWebAuthSettings } from "./settings.js";
20
21
  import { fileURLToPath } from 'node:url';
21
22
  import { join } from 'node:path';
22
23
  /** Stable Cordis plugin name. */
@@ -56,6 +57,11 @@ export function apply(ctx) {
56
57
  // away; failures are logged, never thrown — the client was confirmed.
57
58
  listen: buildListenController(ctx)
58
59
  };
60
+ // The plugin's own Settings Card (设置 → 插件配置 → web-auth) rides the
61
+ // `web-auth` settings namespace: registering it makes the tab dispatch the
62
+ // card, and committed listenHost changes are applied here. Attaches only
63
+ // when a settings service exists (CLI/headless profiles skip it).
64
+ installWebAuthSettings(ctx, env.listen);
59
65
  if (state.readHash() === undefined) {
60
66
  // console.error, not ctx.logger.warn: the cordis default logger level
61
67
  // suppresses warn (level 2) unless the deployment raises it, and this
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-web-auth — the `web-auth` settings namespace.
3
+ *
4
+ * The namespace exists so the plugin's browser half can register its own
5
+ * Settings Card: the settings section ("插件配置" tab) dispatches
6
+ * `settings.plugin.item` for every namespace the Host serves, keyed by the
7
+ * namespace string, and the client registers its card under the same key.
8
+ * The namespace carries the one genuine configuration value the plugin owns:
9
+ * the web server's bind host (`listenHost`). The password is deliberately
10
+ * NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
11
+ * changed through a dedicated API, never through the settings document.
12
+ *
13
+ * Applying `listenHost` still goes through the profile patch layer (the
14
+ * webserver's bind host is composition config; the patch is how it is
15
+ * changed, and HMR hot-applies it). The settings document is the user-facing
16
+ * source of intent; this module translates a committed change into the same
17
+ * prepare → deferred-commit path the REST API used before.
18
+ */
19
+ import z from '@deepseek-ai/schemastery';
20
+ import { type ListenHost } from './profile-patch.ts';
21
+ import type { ListenController } from './handlers.ts';
22
+ /** The settings namespace string (the card's dispatch key, spelled the same client-side). */
23
+ export declare const WEB_AUTH_NS = "web-auth";
24
+ /** Branded namespace for the settings provider. */
25
+ export declare const NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
26
+ /** The namespace's resolved shape. */
27
+ export interface WebAuthSettings {
28
+ /** The web server's bind host. */
29
+ listenHost: ListenHost;
30
+ }
31
+ /** Schema resolving the namespace (schemastery, matching the webserver's own).
32
+ *
33
+ * Deliberately NO `.default(...)`: an invalid user-layer value (e.g. `null`
34
+ * written by an out-of-band client) must REJECT the write — keeping the last
35
+ * good resolved value — rather than silently resolving to the default. A
36
+ * default would look like a genuine user change to the watch below and could
37
+ * rebind the webserver off a value nobody actually chose.
38
+ */
39
+ export declare const WebAuthSettingsSchema: z<WebAuthSettings>;
40
+ /**
41
+ * Register the `web-auth` settings namespace and translate committed
42
+ * `listenHost` changes into the live bind-host switch.
43
+ *
44
+ * The registration rides the plugin fiber and attaches only when a settings
45
+ * service exists (`ctx.inject(['settings'])`), so CLI/headless profiles —
46
+ * which have no web surface and no card — keep working without one. The
47
+ * composition `base` is the webserver's host at boot: the patch layer IS the
48
+ * deployment's composition, and seeding it this way makes the resolved value
49
+ * match reality with no user layer and no settings-document write.
50
+ *
51
+ * The watch callback applies a changed host through the same
52
+ * prepare → deferred-commit controller the REST API uses; a change that is a
53
+ * no-op against the current runtime host is skipped, and a commit that did
54
+ * not actually change the resolved value (invalid writes keep the last good
55
+ * value; document reloads re-resolve) never touches the webserver. This also
56
+ * makes the initial attach harmless — the resolved value equals the runtime
57
+ * host.
58
+ *
59
+ * @param ctx - the plugin context (must expose `settings` when available).
60
+ * @param listen - the live listen-host controller (prepare/commit).
61
+ */
62
+ export declare function installWebAuthSettings(ctx: import('./context-types.ts').PluginContext, listen: ListenController): void;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * dsh-web-auth — the `web-auth` settings namespace.
3
+ *
4
+ * The namespace exists so the plugin's browser half can register its own
5
+ * Settings Card: the settings section ("插件配置" tab) dispatches
6
+ * `settings.plugin.item` for every namespace the Host serves, keyed by the
7
+ * namespace string, and the client registers its card under the same key.
8
+ * The namespace carries the one genuine configuration value the plugin owns:
9
+ * the web server's bind host (`listenHost`). The password is deliberately
10
+ * NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
11
+ * changed through a dedicated API, never through the settings document.
12
+ *
13
+ * Applying `listenHost` still goes through the profile patch layer (the
14
+ * webserver's bind host is composition config; the patch is how it is
15
+ * changed, and HMR hot-applies it). The settings document is the user-facing
16
+ * source of intent; this module translates a committed change into the same
17
+ * prepare → deferred-commit path the REST API used before.
18
+ */
19
+ import z from '@deepseek-ai/schemastery';
20
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
21
+ import { isAllowedListenHost } from "./profile-patch.js";
22
+ /** The settings namespace string (the card's dispatch key, spelled the same client-side). */
23
+ export const WEB_AUTH_NS = 'web-auth';
24
+ /** Branded namespace for the settings provider. */
25
+ export const NS = settingsNamespace(WEB_AUTH_NS);
26
+ /** Schema resolving the namespace (schemastery, matching the webserver's own).
27
+ *
28
+ * Deliberately NO `.default(...)`: an invalid user-layer value (e.g. `null`
29
+ * written by an out-of-band client) must REJECT the write — keeping the last
30
+ * good resolved value — rather than silently resolving to the default. A
31
+ * default would look like a genuine user change to the watch below and could
32
+ * rebind the webserver off a value nobody actually chose.
33
+ */
34
+ export const WebAuthSettingsSchema = z.object({
35
+ listenHost: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')])
36
+ });
37
+ /**
38
+ * Register the `web-auth` settings namespace and translate committed
39
+ * `listenHost` changes into the live bind-host switch.
40
+ *
41
+ * The registration rides the plugin fiber and attaches only when a settings
42
+ * service exists (`ctx.inject(['settings'])`), so CLI/headless profiles —
43
+ * which have no web surface and no card — keep working without one. The
44
+ * composition `base` is the webserver's host at boot: the patch layer IS the
45
+ * deployment's composition, and seeding it this way makes the resolved value
46
+ * match reality with no user layer and no settings-document write.
47
+ *
48
+ * The watch callback applies a changed host through the same
49
+ * prepare → deferred-commit controller the REST API uses; a change that is a
50
+ * no-op against the current runtime host is skipped, and a commit that did
51
+ * not actually change the resolved value (invalid writes keep the last good
52
+ * value; document reloads re-resolve) never touches the webserver. This also
53
+ * makes the initial attach harmless — the resolved value equals the runtime
54
+ * host.
55
+ *
56
+ * @param ctx - the plugin context (must expose `settings` when available).
57
+ * @param listen - the live listen-host controller (prepare/commit).
58
+ */
59
+ export function installWebAuthSettings(ctx, listen) {
60
+ ctx.inject(['settings'], (sctx) => {
61
+ const current = listen.current();
62
+ const base = {
63
+ listenHost: isAllowedListenHost(current) ? current : '127.0.0.1'
64
+ };
65
+ const scope = sctx.settings.register(NS, WebAuthSettingsSchema, { base });
66
+ const dispose = scope.watch((next, prev) => {
67
+ const desired = next.listenHost;
68
+ // Only a real resolved-value change is a user gesture worth applying:
69
+ // a commit that re-resolves to the same value (document reloads, a
70
+ // rejected patch keeping the last good value) must not touch the
71
+ // webserver. Combined with the no-default schema, an invalid write
72
+ // cannot masquerade as a change.
73
+ if (desired === prev.listenHost)
74
+ return;
75
+ if (desired === listen.current())
76
+ return;
77
+ try {
78
+ listen.commit(listen.prepare(desired));
79
+ }
80
+ catch (error) {
81
+ // The settings document already committed; the apply is best-effort
82
+ // (failures logged, never thrown — the client was already confirmed).
83
+ console.error('[dsh-web-auth] failed to apply listenHost from settings:', error);
84
+ }
85
+ });
86
+ sctx.effect(() => dispose, 'web-auth: listen host settings watcher');
87
+ });
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-web-auth",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "DSH web plugin: password-gate authentication (login page + HttpOnly signed cookie) for exposing dsh web behind a reverse proxy",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,10 +31,14 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "rm -rf lib && tsc -p tsconfig.build.json && node scripts/build-client.mjs",
34
+ "prepare": "npm run build",
34
35
  "typecheck": "tsc --noEmit",
35
36
  "test": "npm run build && tsc -p tsconfig.test.json && node --test --test-force-exit 'lib-test/*.test.js'",
36
37
  "test:only": "tsc -p tsconfig.test.json && node --test --test-force-exit 'lib-test/*.test.js'",
37
- "prepublishOnly": "npm run build"
38
+ "prepublishOnly": "npm run build",
39
+ "release:patch": "npm version patch && git push && git push --tags",
40
+ "release:minor": "npm version minor && git push && git push --tags",
41
+ "release:major": "npm version major && git push && git push --tags"
38
42
  },
39
43
  "publishConfig": {
40
44
  "access": "public"
@@ -46,21 +50,32 @@
46
50
  "client": {
47
51
  "inject": [
48
52
  "@deepseek-ai/dsh-client-runtime",
49
- "@deepseek-ai/dsh-client-ui-slots"
53
+ "@deepseek-ai/dsh-client-ui-slots",
54
+ "@deepseek-ai/dsh-client-ui-settings",
55
+ "@deepseek-ai/dsh-client-connection",
56
+ "@deepseek-ai/dsh-api-remotes"
50
57
  ],
51
58
  "platform": "web"
52
59
  }
53
60
  },
54
61
  "peerDependencies": {
55
62
  "@deepseek-ai/cordis": "^4.0.1",
63
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
64
+ "@deepseek-ai/schemastery": "^3.18.1",
56
65
  "react": "^18.2.0",
57
66
  "react-dom": "^18.2.0"
58
67
  },
68
+ "dependencies": {
69
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
70
+ "@deepseek-ai/schemastery": "^3.18.1"
71
+ },
59
72
  "devDependencies": {
60
73
  "@deepseek-ai/cordis": "^4.0.1",
61
74
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
62
75
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
63
76
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
77
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
78
+ "@deepseek-ai/schemastery": "^3.18.1",
64
79
  "@types/node": "^22.0.0",
65
80
  "@types/react": "^18.3.0",
66
81
  "esbuild": "^0.24.0",