kczx-user-management 1.5.6 → 1.6.2
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 +84 -4
- package/client/bundle.js +183 -23
- package/client/index.js +183 -23
- package/docs/dsh-wire-notes.md +854 -0
- package/package.json +2 -2
- package/src/dsh-wire.js +483 -0
- package/src/gateway-core.js +350 -30
- package/src/index.js +44 -2
- package/src/mux-filter.js +263 -0
- package/src/permissions.js +43 -1
- package/src/proxy.js +127 -2
- package/src/sse-filter.js +113 -0
- package/src/store.js +67 -7
- package/src/workspace-create.js +40 -12
package/README.md
CHANGED
|
@@ -87,10 +87,11 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
|
|
|
87
87
|
| 能力 | 说明 |
|
|
88
88
|
|---|---|
|
|
89
89
|
| 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
|
|
90
|
-
|
|
|
91
|
-
| 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness
|
|
90
|
+
| 所属部门(1.6.0 起支持多层级) | 账号资料字段,支持**多层级路径**:用 `/` 分层,例如 `和平分局/刑侦大队/一中队`(`\` 也认,存的时候统一成 `/`)。新增用户时可填,编辑页可随时改或清空;编辑时框下会把层级摊开显示,并列出**已在用的层级**供一键接续;变更写进操作日志。规则(服务端为准,界面同款提示在前面):最多 **5 级**、每一级 ≤ **24 字**、整串 ≤ **64 字**(含分隔符);层级里不能有 `\ / : * ? " < > |`,也不能是 `.` / `..`——因为这些层级要变成目录名。写不进去的值**报错而不是悄悄改写**(理由同长度上限) |
|
|
91
|
+
| 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness 根>/workspace/<部门各级>/<用户名>`,**一级部门一个目录**(`…\workspace\和平分局\刑侦大队\zhang`;可改成任意绝对路径);提交后目录按需创建、登记进 dsh 工作区列表,并把该路径写进这个账号的工作区白名单。部门不合法时**先报 400,再建目录**——不会留下半棵树 |
|
|
92
92
|
| 工作区白名单 | **留空 = 不允许任何工作区**(白名单默认拒绝);填写后只允许这些目录下的工作区;条目 `*` = 允许全部。Windows 路径折叠大小写,末尾斜杠自动忽略 |
|
|
93
93
|
| 工作区归属跟踪 | 谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理 |
|
|
94
|
+
| 会话可见性(1.6.0 起) | 会话按**会话自己的工作目录**判定:目录不在本账号白名单内、或落在别人认领的工作区里,这个会话就不下发。**为什么必须有**:前端把「没有被任何可见工作区认领」的会话统统塞进「未分组」那一组——只藏工作区行的话,里面的会话会带着标题、点开即读的历史原地复活。**三个入口一起堵**:① `session.list`(0.1.1)/ `session/list`(0.1.2+)的一元响应;② 0.1.1 的 `host/session-added` 实时推送(`GET /api/events.host` 的 SSE——每个新建会话都会被推给所有在线浏览器,只改一元响应的话,别人新建一条就会把行实时推回来);③ 0.1.2+ 的 `api-session/added`(`$events` mux 流上的 emit 帧)。`session.search` 沿用列表刚藏掉的 id 清单摘命中片段。判定只看 `cwd`:`blank` **不**豁免(别人目录里的「新建会话」行也还是别人的目录),只有子代理行(`origin: subagent`,前端本来就不列)和**读不到 cwd** 的行保留——「判断不了」不等于「不该看」 |
|
|
94
95
|
| 每小时 token 配额 | 由浏览器上报 dsh 自身的用量投影增量,服务端按小时窗口累计并拦截 |
|
|
95
96
|
| 每日时长配额 | 按真实活跃时间累计(不是请求数),限流不因轮询膨胀 |
|
|
96
97
|
| 沙盒档位下限 | read-only / workspace-write / danger-full-access:AI 无法通过设置、slash 命令或审批把自己提权 |
|
|
@@ -112,6 +113,43 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
|
|
|
112
113
|
默认 50)。筛选同样在服务端执行,所以「共 N 条」永远是**匹配总数**而不是当前页条数。账号表只有在调用方
|
|
113
114
|
传了 `limit` 时才分页,不传仍然整份返回(后端脚本的老用法不受影响)。
|
|
114
115
|
|
|
116
|
+
## dsh 版本适配(1.6.0 起)
|
|
117
|
+
|
|
118
|
+
dsh 有两代 API,本插件两代都认——这曾经是个坑:老代码只认第一代的路径,换到新宿主上**不会报错**,
|
|
119
|
+
只是那些请求永远匹配不上,于是三条强制悄悄失效。
|
|
120
|
+
|
|
121
|
+
| | 0.1.1-rc.2(第一代) | 0.1.2-rc.1+ / 0.1.5-rc.2(第二代) |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| 一元 RPC | `POST /api/<ns>.<method>` | `POST /api/<ns>/<method>` |
|
|
124
|
+
| 工作区列表 | `workspace.list` 一元响应 | `workspace/follow` **流**(走 `/api/remote.mux`) |
|
|
125
|
+
| 会话历史 | `session.history` 一元响应 | `session/page` 一元 + `session/follow` 的开场 `snapshot` |
|
|
126
|
+
| 审批应答 | `POST /api/respond` | `POST /api/$events/result`(事件 id 由 mux 上的 `waterfall` 帧给出) |
|
|
127
|
+
| 大文件外带 | —— | `workspaceFiles/readAll`、`readRelated`(整文件 base64) |
|
|
128
|
+
|
|
129
|
+
因此在 0.1.2-rc.1 及更新的宿主上:
|
|
130
|
+
|
|
131
|
+
- **工作区白名单 / 归属**:过滤 `workspace/follow` 的 baseline / upsert / order 帧(别人的工作区不下发,
|
|
132
|
+
且 upsert 会被整帧丢弃,否则被过滤掉的条目会立刻长回来);
|
|
133
|
+
- **沙盒档位下限**:钳制 `session/page` 的 records、`session/follow` 的 snapshot 与逐条事件、
|
|
134
|
+
以及 `session/control` 上 `permissions` 投影的 `currentValue`;
|
|
135
|
+
- **审批一律拒绝**:把 `$events/result` 的 outcome 改写成 `{kind:'result', value:'rejected'}`。
|
|
136
|
+
判断「这是不是审批」有两条独立依据:mux 上 `approval/request` 的 waterfall 帧记下的 eventId,
|
|
137
|
+
以及 `allowed-once`(审批词表里唯一的放行值,问答的答案不可能是这个字符串);
|
|
138
|
+
- **整文件读取**:`workspaceFiles/readAll|readRelated` 归到「git / 下载」开关下。窗口化的 `read`/`readBytes`
|
|
139
|
+
与 `stat`/`list` 不拦——文件浏览器要靠它们画那一页,否则没开 git 的账号连文件都看不了。
|
|
140
|
+
- **会话可见性**:三个入口共用一条判定(见上表)。两代的一元返回值都是 `{ items: SessionSummary[] }`,
|
|
141
|
+
但**都包在连接信封里**(`{ type:'server-response', rpcId, result:{ ok, value } }`)——只看顶层 `items`
|
|
142
|
+
在真流量上什么都匹配不到(这插件在对话内容台账上踩过一次,1.6.0 又踩了一次,所以
|
|
143
|
+
`dsh-wire.js` 现在带一个信封感知的定位器,测试也一律用真实信封而不是手写的裸形状)。
|
|
144
|
+
`session.search` 沿用同一份「刚被藏掉的 id」清单把命中片段一并去掉(列表是整份快照,每次响应
|
|
145
|
+
**覆盖**该清单而不是累加)。判定走 `workspaceVisibility`,与工作区列表同一套「白名单 + 归属」语义;
|
|
146
|
+
归属按**包含**该目录的最深认领算,所以 `…/mty/src` 里的会话算 `…/mty` 的。
|
|
147
|
+
- **实时推送**:0.1.1 走 `src/sse-filter.js`(逐 SSE 块解析,只丢 `host/session-added` 且判定为不可见的那种块,
|
|
148
|
+
其余字节原样放行);0.1.2+ 复用 mux 拦截器丢 `api-session/added` 帧。两条路都只在**确认识别**时动手,
|
|
149
|
+
认不出来就放行——这条流里跑着整个实时会话(含助手增量),绝不能因为它变哑。
|
|
150
|
+
|
|
151
|
+
宿主源码级的核对记录(每条形状的文件:行)在 [`docs/dsh-wire-notes.md`](docs/dsh-wire-notes.md)。
|
|
152
|
+
|
|
115
153
|
## 配置
|
|
116
154
|
|
|
117
155
|
走 `~/.dsh/settings.yaml` 的 `user-management:` 段。改完即时生效的字段无需重启;只有监听器字段
|
|
@@ -191,10 +229,28 @@ user-management:
|
|
|
191
229
|
- 隐私开关:`user-management.auditConversationText: false`(设置页「会话与请求策略」里也有这一项)关掉即完全不抓。台账是
|
|
192
230
|
`$DSH_HOME/user-management/audit.jsonl` 明文文件,提示词可能含口令 / 密钥时请关闭。
|
|
193
231
|
|
|
232
|
+
### 部门层级(1.6.0 起)
|
|
233
|
+
|
|
234
|
+
部门的「多层级」不是一张独立的部门表,而是**一个字符串里的路径**:`和平分局/刑侦大队/一中队`。这么定有两个好处——老数据不用迁移(原来的 `平台组` 就是单级),而且它正好就是工作区目录的形状:勾了「同时创建该用户的工作区」的账号,目录会一级一个文件夹地铺开
|
|
235
|
+
(都挂在 `<harness 根>/workspace/` 这一层下面)。
|
|
236
|
+
|
|
237
|
+
| 规则 | 值 |
|
|
238
|
+
|---|---|
|
|
239
|
+
| 层级分隔符 | `/`(`\` 也接受,存成 `/`) |
|
|
240
|
+
| 最多层级 | 5 |
|
|
241
|
+
| 每一级最长 | 24 字 |
|
|
242
|
+
| 整串最长 | 64 字(含分隔符) |
|
|
243
|
+
| 层级里禁止 | `\ / : * ? " < > |`,以及 `.` / `..` |
|
|
244
|
+
|
|
245
|
+
空层级会被丢掉,两端空格会被裁掉:`" 和平分局 / 刑侦大队 // 一中队 "` 存成 `和平分局/刑侦大队/一中队`。**不合法就报错**,不会把非法字符悄悄折成 `-`:这条标签同时也是目录名,标签和目录不一致比报错更糟。
|
|
246
|
+
|
|
247
|
+
编辑页与新建对话框里,部门框下面是层级摊开预览(`和平分局 › 刑侦大队`)、已在用层级的快捷前缀(点一下就把框变成 `该路径/`,接着往下敲一级即可),以及和上面同一套规则的即时校验提示。已用层级来自 `GET /user-management/api/departments`(管理员专用,返回**所有前缀**,不只是叶子节点——有人正好挂在中间层时,父级不能消失)。
|
|
248
|
+
|
|
194
249
|
### 新建用户时一并创建工作区(1.5.0 起)
|
|
195
250
|
|
|
196
251
|
「用户 → 新增用户」多了一个复选框「同时创建该用户的工作区」。勾上后出现路径框,**预填**默认路径
|
|
197
|
-
`<harness
|
|
252
|
+
`<harness 根>/workspace/<部门各级>/<用户名>`(部门留空就少一层,只到用户名;所有账号都在同一个 `workspace` 目录下,
|
|
253
|
+
根目录因此不会被一堆部门目录铺满),可以直接改成任意绝对路径;下方一行小字写着默认值,
|
|
198
254
|
改过之后还有「恢复默认」。不勾选则一切照旧。
|
|
199
255
|
|
|
200
256
|
提交时服务端按这个顺序做事,**能失败的都在写账号之前失败**:
|
|
@@ -326,13 +382,25 @@ node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --ove
|
|
|
326
382
|
## 测试
|
|
327
383
|
|
|
328
384
|
```bash
|
|
329
|
-
npm test #
|
|
385
|
+
npm test # 248 项:策略纯函数、store、API 层、客户端契约、以及真实网关的端到端用例
|
|
330
386
|
npm run check # 全量语法检查
|
|
331
387
|
```
|
|
332
388
|
|
|
333
389
|
端到端用例会起真实网关(明文模式 + 假上游)验证:工作区过滤含 gzip、目录门与请求体重放、沙盒提权拦截与审批改写、
|
|
334
390
|
上传 / git / 运维面门控、历史降级与隐藏字符清洗、配额拦截、归属认领与释放。
|
|
335
391
|
|
|
392
|
+
`test/dsh-0.1.5-wire.test.mjs` 另外覆盖第二代 wire:假的 dsh 上游在 `/api/remote.mux` 上做真实的
|
|
393
|
+
WebSocket 握手,按 `streamId` 收发 JSON 文本帧,验证 `workspace/follow` 的 baseline / upsert / order 过滤、
|
|
394
|
+
`session/follow` 的 snapshot 与逐条事件钳制、`$events` waterfall 记录 + `$events/result` 审批拒绝、
|
|
395
|
+
`session/page` 改写、`session/control` 的权限投影钳制、以及 `workspaceFiles/readAll` 门控;
|
|
396
|
+
纯函数部分(`dsh-wire.js` 的帧计划、`mux-filter.js` 的编解码)单独覆盖丢弃 / 透传 / 分片放行语义。
|
|
397
|
+
|
|
398
|
+
`test/session-visibility.test.mjs` 覆盖会话可见性:越界 cwd 的行被摘掉、没 cwd 与子代理行保留、
|
|
399
|
+
别人认领目录(含其子目录)里的会话被摘掉(`blank` 也照摘)、自己认领的共享目录不摘、管理员原样、
|
|
400
|
+
空白名单全摘、`session.search` 跟随列表的隐藏清单、以及 **SSE 那一路**——假上游按真实
|
|
401
|
+
`data: {…}\n\n` 写 `host/session-added`,且**故意从块中间切成两个 chunk** 写出来,验证缓冲与逐块判定。
|
|
402
|
+
所有响应都用真实信封形状,裸 `{ items }` 只作为兼容形状另测一次。
|
|
403
|
+
|
|
336
404
|
## 卸载 / 回滚
|
|
337
405
|
|
|
338
406
|
```bash
|
|
@@ -353,6 +421,18 @@ dsh plugin --profile web remove kczx-user-management
|
|
|
353
421
|
- **会话历史要缓冲后才能改写**:历史响应是**一个 JSON 文档**(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符
|
|
354
422
|
清洗,因此有 `historyMaxBytes` 上限(默认 64 MiB)。超过上限的会话会 **fail-closed 返回 502**,UI 显示「历史加载失败」——
|
|
355
423
|
调大该值即可;1.0.0 之前用代理默认的 8 MiB,长会话必然踩中。
|
|
424
|
+
- **第二代 wire 的强制有两条前提**:一是浏览器走本网关(直连宿主端口没有任何强制,见上),二是帧是
|
|
425
|
+
**单帧 JSON 文本**。分片的 WebSocket 消息、二进制帧、以及 ping/pong 控制帧一律原样放行——为了一个分片帧去缓冲
|
|
426
|
+
别人整条流不划算;`ws` 宿主与浏览器发这些控制消息都不分片,所以这条边界在实践中碰不到。
|
|
427
|
+
- **SSE 过滤会「认输」**:一个块迟迟不结束、或缓冲超过 1 MiB,就原样转发并停止改写这条连接(宁可漏拦,不能把实时流卡住)。
|
|
428
|
+
- **A 方案只挡「看得到」**:知道 sessionId 仍然能通过 `session/page` / `session/follow`(0.1.1 是 `session.history`)
|
|
429
|
+
读别人的会话——那是 B 方案的事。
|
|
430
|
+
- **审批记忆是进程内的**:网关在 mux 上看到 `approval/request` 的 waterfall 帧后记住它的 eventId。
|
|
431
|
+
网关重启期间若浏览器重连,宿主会重放未决的 waterfall(同一个 eventId),记忆随之重建;万一两条依据都没命中
|
|
432
|
+
(既没记住、值也不是 `allowed-once`),该次应答按原样放行——这是有意的取舍:把 `$events/result` 一律拒掉
|
|
433
|
+
会连 `ask_user_question` 的答案一起吃掉。
|
|
434
|
+
- **整文件读取按「整文件」划界**:`readAll` / `readRelated` 受开关管,窗口化的 `readBytes` 不受管——反复调它理论上
|
|
435
|
+
也能把文件搬走,但它是图片预览与分页阅读的底座,关掉会连文件浏览器一起关掉。
|
|
356
436
|
- 上游 `@weibaohui/user-management`(0.9.5)与本包 `kczx-user-management` 是两个不同的包,互不覆盖,`pnpm update` 不会把两者串在一起。
|
|
357
437
|
|
|
358
438
|
## License
|
package/client/bundle.js
CHANGED
|
@@ -124,10 +124,20 @@ window.__ModuleLoader__.load({
|
|
|
124
124
|
colPassword: '密码(至少 6 位)',
|
|
125
125
|
colRolePick: '角色',
|
|
126
126
|
colDepartment: '所属部门',
|
|
127
|
-
departmentPlaceholder: '
|
|
127
|
+
departmentPlaceholder: '所属部门(可选,用 / 分层)',
|
|
128
128
|
departmentUnset: '未设置',
|
|
129
|
-
editDepartmentHint: '
|
|
129
|
+
editDepartmentHint: '留空表示清除该用户的所属部门;用 / 分层,例如「和平分局/刑侦大队/一中队」。',
|
|
130
130
|
departmentUpdated: '所属部门已更新',
|
|
131
|
+
departmentLevelsLabel: '层级',
|
|
132
|
+
departmentPickHint: '点一个已有层级接着往下分:',
|
|
133
|
+
departmentNoSuggestions: '还没有任何部门层级,直接输入第一级即可。',
|
|
134
|
+
departmentClear: '清除',
|
|
135
|
+
departmentBadChar: '层级里不能有 \\ / : * ? " < > | 这些字符',
|
|
136
|
+
departmentBadDot: '层级不能是 . 或 ..',
|
|
137
|
+
departmentTooDeep: '最多 {n} 级',
|
|
138
|
+
departmentLevelTooLong: '每一级最多 {n} 字',
|
|
139
|
+
departmentTooLong: '整串最多 {n} 字(含 /)',
|
|
140
|
+
departmentAddChild: '+ 下一级',
|
|
131
141
|
// ── 新建用户时一并建工作区 ────────────────────────────────────────────────
|
|
132
142
|
createWorkspace: '同时创建该用户的工作区',
|
|
133
143
|
workspacePathLabel: '工作区路径',
|
|
@@ -299,7 +309,7 @@ window.__ModuleLoader__.load({
|
|
|
299
309
|
cfgFieldOtpFailLimit: '两步验证失败上限',
|
|
300
310
|
cfgFieldOtpLockoutSeconds: '两步验证锁定时长(秒)',
|
|
301
311
|
cfgFieldWorkspaceRoot: '新账号工作区根路径',
|
|
302
|
-
cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为
|
|
312
|
+
cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为 <根路径>/workspace/<部门各级>/<用户名>。留空 = 用宿主提供的工作区根路径(dsh 默认是 harness 所在目录)。',
|
|
303
313
|
}
|
|
304
314
|
|
|
305
315
|
const EN = {
|
|
@@ -381,10 +391,20 @@ window.__ModuleLoader__.load({
|
|
|
381
391
|
colPassword: 'Password (min 6 chars)',
|
|
382
392
|
colRolePick: 'Role',
|
|
383
393
|
colDepartment: 'Department',
|
|
384
|
-
departmentPlaceholder: 'Department (optional,
|
|
394
|
+
departmentPlaceholder: 'Department (optional, levels separated by /)',
|
|
385
395
|
departmentUnset: 'Not set',
|
|
386
|
-
editDepartmentHint: 'Leave empty to clear this account’s department.',
|
|
396
|
+
editDepartmentHint: 'Leave empty to clear this account’s department; separate levels with / — e.g. “HQ/Criminal/Team 1”.',
|
|
387
397
|
departmentUpdated: 'Department updated',
|
|
398
|
+
departmentLevelsLabel: 'Levels',
|
|
399
|
+
departmentPickHint: 'Pick an existing level to extend it:',
|
|
400
|
+
departmentNoSuggestions: 'No department levels yet — just type the first one.',
|
|
401
|
+
departmentClear: 'Clear',
|
|
402
|
+
departmentBadChar: 'A level cannot contain \\ / : * ? " < > |',
|
|
403
|
+
departmentBadDot: 'A level cannot be . or ..',
|
|
404
|
+
departmentTooDeep: 'At most {n} levels',
|
|
405
|
+
departmentLevelTooLong: 'Each level is at most {n} characters',
|
|
406
|
+
departmentTooLong: 'At most {n} characters in total (including /)',
|
|
407
|
+
departmentAddChild: '+ add a level',
|
|
388
408
|
// ── workspace created together with the account ───────────────────────────
|
|
389
409
|
createWorkspace: 'Also create this account’s workspace',
|
|
390
410
|
workspacePathLabel: 'Workspace path',
|
|
@@ -556,7 +576,7 @@ window.__ModuleLoader__.load({
|
|
|
556
576
|
cfgFieldOtpFailLimit: '2FA failure limit',
|
|
557
577
|
cfgFieldOtpLockoutSeconds: '2FA lockout (seconds)',
|
|
558
578
|
cfgFieldWorkspaceRoot: 'Workspace root for new accounts',
|
|
559
|
-
cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root
|
|
579
|
+
cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root>/workspace/<department levels>/<username>. Empty = use the host’s own workspace root (dsh defaults to the harness directory).',
|
|
560
580
|
}
|
|
561
581
|
|
|
562
582
|
/** Ledger event type → locale key (also the login-log type filter). */
|
|
@@ -607,12 +627,66 @@ window.__ModuleLoader__.load({
|
|
|
607
627
|
return out
|
|
608
628
|
}
|
|
609
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Department shape the store enforces (src/store.js). Mirrored rather than
|
|
632
|
+
* imported: this half is a browser bundle and cannot reach the host module, so
|
|
633
|
+
* the constants are repeated HERE and in the server, and the server stays the
|
|
634
|
+
* one that decides.
|
|
635
|
+
*/
|
|
636
|
+
const DEPARTMENT_MAX_DEPTH = 5
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The directory every default workspace lives under (mirror of
|
|
640
|
+
* WORKSPACE_SUBDIR in src/workspace-create.js): the preview must name the same
|
|
641
|
+
* folder the server will build, level for level.
|
|
642
|
+
*/
|
|
643
|
+
const WORKSPACE_SUBDIR = 'workspace'
|
|
644
|
+
const DEPARTMENT_MAX_LEVEL_LEN = 24
|
|
645
|
+
const DEPARTMENT_MAX_LEN = 64
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The levels of a department label.
|
|
649
|
+
*
|
|
650
|
+
* Client twin of the store's `departmentLevels`: both separators are accepted,
|
|
651
|
+
* every level is trimmed, empties are dropped. Tolerant on purpose — this side
|
|
652
|
+
* only previews and warns; the server's normalized answer is what gets stored.
|
|
653
|
+
*/
|
|
654
|
+
function departmentLevels(value) {
|
|
655
|
+
return String(value === undefined || value === null ? '' : value)
|
|
656
|
+
.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
657
|
+
.split(/[\\/]+/)
|
|
658
|
+
.map((level) => level.trim())
|
|
659
|
+
.filter((level) => level !== '')
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* The first thing wrong with a department label, or null.
|
|
664
|
+
*
|
|
665
|
+
* Mirrors the store's refusals so the admin sees the problem while typing
|
|
666
|
+
* instead of after a round trip. The server still validates — this is advice,
|
|
667
|
+
* not the gate.
|
|
668
|
+
*/
|
|
669
|
+
function departmentProblem(levels, t) {
|
|
670
|
+
if (levels.length > DEPARTMENT_MAX_DEPTH) return interpolate(t('departmentTooDeep'), { n: DEPARTMENT_MAX_DEPTH })
|
|
671
|
+
for (const level of levels) {
|
|
672
|
+
if (level === '.' || level === '..') return t('departmentBadDot')
|
|
673
|
+
if (/[\\/:*?"<>|]/.test(level)) return t('departmentBadChar')
|
|
674
|
+
if (level.length > DEPARTMENT_MAX_LEVEL_LEN) {
|
|
675
|
+
return interpolate(t('departmentLevelTooLong'), { n: DEPARTMENT_MAX_LEVEL_LEN })
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (levels.join('/').length > DEPARTMENT_MAX_LEN) {
|
|
679
|
+
return interpolate(t('departmentTooLong'), { n: DEPARTMENT_MAX_LEN })
|
|
680
|
+
}
|
|
681
|
+
return null
|
|
682
|
+
}
|
|
683
|
+
|
|
610
684
|
/**
|
|
611
685
|
* The default workspace path, as the create-user dialog shows it:
|
|
612
|
-
* `<root
|
|
686
|
+
* `<root>/workspace/<一级>/<二级>/…/<username>`.
|
|
613
687
|
*
|
|
614
688
|
* This is the DISPLAY twin of the host half's rule (src/workspace-create.js):
|
|
615
|
-
*
|
|
689
|
+
* one folder PER department level, each folded into a directory name, joined
|
|
616
690
|
* with the separator the server reported. The server recomputes the path from
|
|
617
691
|
* its own module on submit and its answer is the one that counts — the two must
|
|
618
692
|
* agree, which is why the box shows the default rather than inventing one.
|
|
@@ -622,8 +696,9 @@ window.__ModuleLoader__.load({
|
|
|
622
696
|
// No root = no preview: the dialog then asks for an absolute path instead of
|
|
623
697
|
// showing a path relative to nothing.
|
|
624
698
|
if (base === '') return ''
|
|
625
|
-
const parts = [base]
|
|
626
|
-
|
|
699
|
+
const parts = [base, WORKSPACE_SUBDIR]
|
|
700
|
+
// The username is folded by the same rule as a level: it is one segment too.
|
|
701
|
+
for (const raw of [...departmentLevels(department), username]) {
|
|
627
702
|
const segment = String(raw === undefined || raw === null ? '' : raw)
|
|
628
703
|
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, '-')
|
|
629
704
|
.replace(/-{2,}/g, '-')
|
|
@@ -634,6 +709,53 @@ window.__ModuleLoader__.load({
|
|
|
634
709
|
return parts.join(sep || '/')
|
|
635
710
|
}
|
|
636
711
|
|
|
712
|
+
/**
|
|
713
|
+
* The department box: one text field, its levels spelled out underneath, and the
|
|
714
|
+
* levels already in use as one-click prefixes.
|
|
715
|
+
*
|
|
716
|
+
* The stored value stays a single string — the tree lives in the '/' separators,
|
|
717
|
+
* which is what the workspace folders are built from — so the picker's job is
|
|
718
|
+
* only to make the next level cheap to type: click a known path and the field
|
|
719
|
+
* becomes `path/`, ready for the new level.
|
|
720
|
+
*/
|
|
721
|
+
function DepartmentField({ id, value, onChange, suggestions, __t: t }) {
|
|
722
|
+
const levels = departmentLevels(value)
|
|
723
|
+
const problem = departmentProblem(levels, t)
|
|
724
|
+
const known = Array.isArray(suggestions) ? suggestions : []
|
|
725
|
+
return h('div', { className: 'um-dept' },
|
|
726
|
+
h('input', {
|
|
727
|
+
id,
|
|
728
|
+
className: 'um-input',
|
|
729
|
+
value,
|
|
730
|
+
maxLength: DEPARTMENT_MAX_LEN,
|
|
731
|
+
list: problem === null ? `${id}-known` : undefined,
|
|
732
|
+
placeholder: t('departmentPlaceholder'),
|
|
733
|
+
onChange: (e) => onChange(e.target.value),
|
|
734
|
+
}),
|
|
735
|
+
problem === null ? null : h('div', { className: 'um-dept-problem' }, problem),
|
|
736
|
+
levels.length === 0 ? null : h('div', { className: 'um-dept-levels' },
|
|
737
|
+
h('span', { className: 'um-muted' }, `${t('departmentLevelsLabel')}:`),
|
|
738
|
+
levels.map((level, index) => h('span', { key: `${index}-${level}`, className: 'um-dept-level' }, level))),
|
|
739
|
+
h('datalist', { id: `${id}-known` }, known.map((path) => h('option', { key: path, value: path }))),
|
|
740
|
+
known.length === 0
|
|
741
|
+
? h('div', { className: 'um-muted um-dept-hint' }, t('departmentNoSuggestions'))
|
|
742
|
+
: h('div', { className: 'um-dept-hint' },
|
|
743
|
+
h('span', { className: 'um-muted' }, t('departmentPickHint')),
|
|
744
|
+
known.map((path) => h('button', {
|
|
745
|
+
key: path,
|
|
746
|
+
type: 'button',
|
|
747
|
+
className: 'um-dept-pick',
|
|
748
|
+
title: `${path}/`,
|
|
749
|
+
onClick: () => onChange(`${path}/`),
|
|
750
|
+
}, path))),
|
|
751
|
+
levels.length === 0 ? null : h('button', {
|
|
752
|
+
type: 'button',
|
|
753
|
+
className: 'um-dept-clear',
|
|
754
|
+
onClick: () => onChange(''),
|
|
755
|
+
}, t('departmentClear')),
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
|
|
637
759
|
/** A blank create-user form. A factory, not a shared literal: closing the
|
|
638
760
|
* dialog must hand back a fresh object, or React would keep rendering the
|
|
639
761
|
* previous edit's values. */
|
|
@@ -737,6 +859,17 @@ window.__ModuleLoader__.load({
|
|
|
737
859
|
.um-form { display: flex; flex-direction: column; gap: 8px; max-width: 340px; }
|
|
738
860
|
.um-form label { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
739
861
|
/* Create-user dialog: the optional workspace block (checkbox → path + hints). */
|
|
862
|
+
/* Department box: the field, its levels, and the known-level prefixes. */
|
|
863
|
+
.um-dept { display: flex; flex-direction: column; gap: 6px; }
|
|
864
|
+
.um-dept .um-input { width: 100%; }
|
|
865
|
+
.um-dept-problem { font-size: 11px; line-height: 1.5; color: var(--dsw-alias-state-error-primary); }
|
|
866
|
+
.um-dept-levels { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 11px; }
|
|
867
|
+
.um-dept-level { padding: 1px 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); }
|
|
868
|
+
.um-dept-level + .um-dept-level::before { content: '›'; margin-right: 4px; color: var(--dsw-alias-label-tertiary); }
|
|
869
|
+
.um-dept-hint { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 11px; line-height: 1.6; }
|
|
870
|
+
.um-dept-pick { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); border-radius: 4px; padding: 1px 6px; font-size: 11px; cursor: pointer; }
|
|
871
|
+
.um-dept-pick:hover { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
|
|
872
|
+
.um-dept-clear { align-self: flex-start; border: 0; background: transparent; color: var(--dsw-alias-state-business-primary); padding: 0; font-size: 11px; cursor: pointer; }
|
|
740
873
|
.um-ws { display: flex; flex-direction: column; gap: 6px; }
|
|
741
874
|
.um-ws .um-input { width: 100%; }
|
|
742
875
|
.um-hint { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; line-height: 1.5; color: var(--dsw-alias-label-tertiary); }
|
|
@@ -1220,7 +1353,7 @@ window.__ModuleLoader__.load({
|
|
|
1220
1353
|
* workspace (the whitelist denies by default, and '*' is the wide grant), an
|
|
1221
1354
|
* empty limit means no cap — clearing a field is a visible, deliberate act
|
|
1222
1355
|
* rather than a silent default. */
|
|
1223
|
-
function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
|
|
1356
|
+
function EditUserPage({ user, me, departments, onClose, onDone, onTempPassword, __t: t }) {
|
|
1224
1357
|
const p = user.permissions || {}
|
|
1225
1358
|
const isSelf = user.id === me.id
|
|
1226
1359
|
const [department, setDepartment] = useState(user.department || '')
|
|
@@ -1344,10 +1477,9 @@ window.__ModuleLoader__.load({
|
|
|
1344
1477
|
h('label', { htmlFor: 'um-edit-username' }, t('colUser')),
|
|
1345
1478
|
h('input', { id: 'um-edit-username', className: 'um-input', value: user.username, disabled: true }),
|
|
1346
1479
|
h('label', { htmlFor: 'um-edit-department' }, t('colDepartment')),
|
|
1347
|
-
h(
|
|
1348
|
-
id: 'um-edit-department',
|
|
1349
|
-
|
|
1350
|
-
onChange: (e) => setDepartment(e.target.value),
|
|
1480
|
+
h(DepartmentField, {
|
|
1481
|
+
id: 'um-edit-department', value: department, onChange: setDepartment,
|
|
1482
|
+
suggestions: departments, __t: t,
|
|
1351
1483
|
}),
|
|
1352
1484
|
h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('editDepartmentHint')),
|
|
1353
1485
|
h('label', { htmlFor: 'um-edit-role' }, t('colRolePick')),
|
|
@@ -1451,7 +1583,8 @@ window.__ModuleLoader__.load({
|
|
|
1451
1583
|
*
|
|
1452
1584
|
* Split out from the tab that opens it because the workspace half is the part
|
|
1453
1585
|
* worth pinning down: the checkbox reveals a path box pre-filled with the
|
|
1454
|
-
* default (`<harness root
|
|
1586
|
+
* default (`<harness root>/workspace/<department levels>/<username>`) and left
|
|
1587
|
+
* editable, so the
|
|
1455
1588
|
* admin either accepts it or types another absolute path. As a component of its
|
|
1456
1589
|
* own it can be rendered on demand — the tab's dialog otherwise only exists
|
|
1457
1590
|
* after a click, which a contract test cannot perform.
|
|
@@ -1461,7 +1594,7 @@ window.__ModuleLoader__.load({
|
|
|
1461
1594
|
* could not be read: the dialog then asks for an absolute path rather than
|
|
1462
1595
|
* showing a path relative to nothing.
|
|
1463
1596
|
*/
|
|
1464
|
-
function CreateUserDialog({ form, setForm, wsDefault, wsTouched, onTypePath, onResetPath, onClose, onSubmit, __t: t }) {
|
|
1597
|
+
function CreateUserDialog({ form, setForm, wsDefault, wsTouched, departments, onTypePath, onResetPath, onClose, onSubmit, __t: t }) {
|
|
1465
1598
|
// Untouched means "the default" — shown, but sent to the server as blank so it
|
|
1466
1599
|
// builds the path itself from its own root and its own sanitizing rules.
|
|
1467
1600
|
const path = wsTouched ? form.workspacePath : wsDefault
|
|
@@ -1478,10 +1611,12 @@ window.__ModuleLoader__.load({
|
|
|
1478
1611
|
h('label', { htmlFor: 'um-new-password' }, t('colPassword')),
|
|
1479
1612
|
h('input', { id: 'um-new-password', className: 'um-input', type: 'password', autoComplete: 'new-password', minLength: 6, value: form.password, onChange: (e) => setForm({ ...form, password: e.target.value }), required: true }),
|
|
1480
1613
|
h('label', { htmlFor: 'um-new-department' }, t('colDepartment')),
|
|
1481
|
-
h(
|
|
1482
|
-
id: 'um-new-department',
|
|
1483
|
-
|
|
1484
|
-
onChange: (
|
|
1614
|
+
h(DepartmentField, {
|
|
1615
|
+
id: 'um-new-department',
|
|
1616
|
+
value: form.department,
|
|
1617
|
+
onChange: (department) => setForm({ ...form, department }),
|
|
1618
|
+
suggestions: departments,
|
|
1619
|
+
__t: t,
|
|
1485
1620
|
}),
|
|
1486
1621
|
// The optional workspace: one checkbox, then the folder it will live in.
|
|
1487
1622
|
h('label', { className: 'um-row', style: { gap: 6 } },
|
|
@@ -1523,6 +1658,10 @@ window.__ModuleLoader__.load({
|
|
|
1523
1658
|
// reported it ({root, sep}); null until it answers. A path is not something to
|
|
1524
1659
|
// guess at, so the dialog asks rather than assuming a layout.
|
|
1525
1660
|
const [wsRoot, setWsRoot] = useState(null)
|
|
1661
|
+
// The department paths in use, for the level picker in both dialogs. Derived
|
|
1662
|
+
// from EVERY account, so it is fetched rather than scraped off the current
|
|
1663
|
+
// page of the paged roster.
|
|
1664
|
+
const [departments, setDepartments] = useState([])
|
|
1526
1665
|
// Whether the admin typed in the path box. Untouched means "the default",
|
|
1527
1666
|
// which is recomputed from the username/department as they are typed and left
|
|
1528
1667
|
// for the server to build for real on submit.
|
|
@@ -1556,12 +1695,25 @@ window.__ModuleLoader__.load({
|
|
|
1556
1695
|
*/
|
|
1557
1696
|
const openCreate = () => {
|
|
1558
1697
|
setCreating(true)
|
|
1698
|
+
loadDepartments()
|
|
1559
1699
|
if (wsRoot !== null) return
|
|
1560
1700
|
api('/workspace-root')
|
|
1561
1701
|
.then((d) => setWsRoot({ root: String(d.root || ''), sep: String(d.sep || '/') }))
|
|
1562
1702
|
.catch(() => setWsRoot({ root: '', sep: '/' }))
|
|
1563
1703
|
}
|
|
1564
1704
|
|
|
1705
|
+
/**
|
|
1706
|
+
* Refresh the known department paths.
|
|
1707
|
+
*
|
|
1708
|
+
* Called whenever a form that can change them opens or submits, so the picker
|
|
1709
|
+
* offers what the roster actually contains — a stale list would quietly drop
|
|
1710
|
+
* the level somebody just created.
|
|
1711
|
+
*/
|
|
1712
|
+
const loadDepartments = () =>
|
|
1713
|
+
api('/departments')
|
|
1714
|
+
.then((d) => setDepartments(Array.isArray(d.departments) ? d.departments : []))
|
|
1715
|
+
.catch(() => setDepartments([]))
|
|
1716
|
+
|
|
1565
1717
|
const closeCreate = () => {
|
|
1566
1718
|
setCreating(false)
|
|
1567
1719
|
setForm(NEW_USER_FORM())
|
|
@@ -1580,6 +1732,9 @@ window.__ModuleLoader__.load({
|
|
|
1580
1732
|
|
|
1581
1733
|
const submitCreate = (e) => {
|
|
1582
1734
|
e.preventDefault()
|
|
1735
|
+
// A new account may introduce a level; the picker should learn it without a
|
|
1736
|
+
// page reload.
|
|
1737
|
+
loadDepartments()
|
|
1583
1738
|
// An untouched path box sends an EMPTY path: the server then builds the
|
|
1584
1739
|
// default from its own root and its own sanitizing rules, so the folder can
|
|
1585
1740
|
// never be a stale copy of what this form happened to display.
|
|
@@ -1640,7 +1795,10 @@ window.__ModuleLoader__.load({
|
|
|
1640
1795
|
? h('div', { className: 'um-muted', style: { fontSize: 11, whiteSpace: 'nowrap' } }, usageSummary(user, t))
|
|
1641
1796
|
: null)),
|
|
1642
1797
|
h('td', null, h('div', { className: 'um-row', style: { justifyContent: 'flex-end' } },
|
|
1643
|
-
h('button', {
|
|
1798
|
+
h('button', {
|
|
1799
|
+
className: 'um-btn um-btn-primary',
|
|
1800
|
+
onClick: () => { loadDepartments(); setEditUser(user) },
|
|
1801
|
+
}, t('actionEdit'))))))))),
|
|
1644
1802
|
h(Pager, {
|
|
1645
1803
|
page,
|
|
1646
1804
|
pages: auditPageCount(total, pageSize),
|
|
@@ -1658,6 +1816,7 @@ window.__ModuleLoader__.load({
|
|
|
1658
1816
|
// department and the username as they are typed); the dialog only draws it.
|
|
1659
1817
|
wsDefault,
|
|
1660
1818
|
wsTouched,
|
|
1819
|
+
departments,
|
|
1661
1820
|
onTypePath: () => setWsTouched(true),
|
|
1662
1821
|
onResetPath: () => setWsTouched(false),
|
|
1663
1822
|
onClose: closeCreate,
|
|
@@ -1667,6 +1826,7 @@ window.__ModuleLoader__.load({
|
|
|
1667
1826
|
editUser && h(EditUserPage, {
|
|
1668
1827
|
user: editUser,
|
|
1669
1828
|
me,
|
|
1829
|
+
departments,
|
|
1670
1830
|
onClose: () => setEditUser(null),
|
|
1671
1831
|
// The page closes on any write that changes the account, so the row behind
|
|
1672
1832
|
// it is re-read instead of being reconciled field by field.
|
|
@@ -2742,7 +2902,7 @@ window.__ModuleLoader__.load({
|
|
|
2742
2902
|
__internals: {
|
|
2743
2903
|
NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, auditPageCount,
|
|
2744
2904
|
AUDIT_PAGE_SIZES, AUDIT_PAGE_SIZE_DEFAULT, AuditTab, UsersTab, ActivityTab, Pager, avatarHue,
|
|
2745
|
-
workspaceDefaultPath, NEW_USER_FORM, CreateUserDialog, ConsoleHead, ConsoleCorner, BackToSettings, ShellUserMenu,
|
|
2905
|
+
workspaceDefaultPath, departmentLevels, departmentProblem, DepartmentField, NEW_USER_FORM, CreateUserDialog, ConsoleHead, ConsoleCorner, BackToSettings, ShellUserMenu,
|
|
2746
2906
|
setFullPageOverlay, fullPageOverlayOpen,
|
|
2747
2907
|
UserAvatar, HeaderUserMenu, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, LogoutButton, doLogout,
|
|
2748
2908
|
CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, MyPanel, EditUserPage, TotpCard, AuditTextCell,
|