kczx-user-management 1.5.6 → 1.6.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/README.md CHANGED
@@ -87,10 +87,11 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
87
87
  | 能力 | 说明 |
88
88
  |---|---|
89
89
  | 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
90
- | 所属部门 | 账号资料字段(trim 后 ≤ 64 字,控制字符剔除):新增用户时可填,编辑页可随时改或清空;变更写进操作日志 |
91
- | 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness 根>/<部门>/<用户名>`(可改成任意绝对路径);提交后目录按需创建、登记进 dsh 工作区列表,并把该路径写进这个账号的工作区白名单 |
90
+ | 所属部门(1.6.0 起支持多层级) | 账号资料字段,支持**多层级路径**:用 `/` 分层,例如 `和平分局/刑侦大队/一中队`(`\` 也认,存的时候统一成 `/`)。新增用户时可填,编辑页可随时改或清空;编辑时框下会把层级摊开显示,并列出**已在用的层级**供一键接续;变更写进操作日志。规则(服务端为准,界面同款提示在前面):最多 **5 级**、每一级 ≤ **24 字**、整串 ≤ **64 字**(含分隔符);层级里不能有 `\ / : * ? " < > |`,也不能是 `.` / `..`——因为这些层级要变成目录名。写不进去的值**报错而不是悄悄改写**(理由同长度上限) |
91
+ | 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness 根>/<部门各级>/<用户名>`,**一级部门一个目录**(`…\和平分局\刑侦大队\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,6 +229,22 @@ 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
+
236
+ | 规则 | 值 |
237
+ |---|---|
238
+ | 层级分隔符 | `/`(`\` 也接受,存成 `/`) |
239
+ | 最多层级 | 5 |
240
+ | 每一级最长 | 24 字 |
241
+ | 整串最长 | 64 字(含分隔符) |
242
+ | 层级里禁止 | `\ / : * ? " < > |`,以及 `.` / `..` |
243
+
244
+ 空层级会被丢掉,两端空格会被裁掉:`" 和平分局 / 刑侦大队 // 一中队 "` 存成 `和平分局/刑侦大队/一中队`。**不合法就报错**,不会把非法字符悄悄折成 `-`:这条标签同时也是目录名,标签和目录不一致比报错更糟。
245
+
246
+ 编辑页与新建对话框里,部门框下面是层级摊开预览(`和平分局 › 刑侦大队`)、已在用层级的快捷前缀(点一下就把框变成 `该路径/`,接着往下敲一级即可),以及和上面同一套规则的即时校验提示。已用层级来自 `GET /user-management/api/departments`(管理员专用,返回**所有前缀**,不只是叶子节点——有人正好挂在中间层时,父级不能消失)。
247
+
194
248
  ### 新建用户时一并创建工作区(1.5.0 起)
195
249
 
196
250
  「用户 → 新增用户」多了一个复选框「同时创建该用户的工作区」。勾上后出现路径框,**预填**默认路径
@@ -326,13 +380,25 @@ node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --ove
326
380
  ## 测试
327
381
 
328
382
  ```bash
329
- npm test # 203 项:策略纯函数、store、API 层、客户端契约、以及真实网关的端到端用例
383
+ npm test # 248 项:策略纯函数、store、API 层、客户端契约、以及真实网关的端到端用例
330
384
  npm run check # 全量语法检查
331
385
  ```
332
386
 
333
387
  端到端用例会起真实网关(明文模式 + 假上游)验证:工作区过滤含 gzip、目录门与请求体重放、沙盒提权拦截与审批改写、
334
388
  上传 / git / 运维面门控、历史降级与隐藏字符清洗、配额拦截、归属认领与释放。
335
389
 
390
+ `test/dsh-0.1.5-wire.test.mjs` 另外覆盖第二代 wire:假的 dsh 上游在 `/api/remote.mux` 上做真实的
391
+ WebSocket 握手,按 `streamId` 收发 JSON 文本帧,验证 `workspace/follow` 的 baseline / upsert / order 过滤、
392
+ `session/follow` 的 snapshot 与逐条事件钳制、`$events` waterfall 记录 + `$events/result` 审批拒绝、
393
+ `session/page` 改写、`session/control` 的权限投影钳制、以及 `workspaceFiles/readAll` 门控;
394
+ 纯函数部分(`dsh-wire.js` 的帧计划、`mux-filter.js` 的编解码)单独覆盖丢弃 / 透传 / 分片放行语义。
395
+
396
+ `test/session-visibility.test.mjs` 覆盖会话可见性:越界 cwd 的行被摘掉、没 cwd 与子代理行保留、
397
+ 别人认领目录(含其子目录)里的会话被摘掉(`blank` 也照摘)、自己认领的共享目录不摘、管理员原样、
398
+ 空白名单全摘、`session.search` 跟随列表的隐藏清单、以及 **SSE 那一路**——假上游按真实
399
+ `data: {…}\n\n` 写 `host/session-added`,且**故意从块中间切成两个 chunk** 写出来,验证缓冲与逐块判定。
400
+ 所有响应都用真实信封形状,裸 `{ items }` 只作为兼容形状另测一次。
401
+
336
402
  ## 卸载 / 回滚
337
403
 
338
404
  ```bash
@@ -353,6 +419,18 @@ dsh plugin --profile web remove kczx-user-management
353
419
  - **会话历史要缓冲后才能改写**:历史响应是**一个 JSON 文档**(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符
354
420
  清洗,因此有 `historyMaxBytes` 上限(默认 64 MiB)。超过上限的会话会 **fail-closed 返回 502**,UI 显示「历史加载失败」——
355
421
  调大该值即可;1.0.0 之前用代理默认的 8 MiB,长会话必然踩中。
422
+ - **第二代 wire 的强制有两条前提**:一是浏览器走本网关(直连宿主端口没有任何强制,见上),二是帧是
423
+ **单帧 JSON 文本**。分片的 WebSocket 消息、二进制帧、以及 ping/pong 控制帧一律原样放行——为了一个分片帧去缓冲
424
+ 别人整条流不划算;`ws` 宿主与浏览器发这些控制消息都不分片,所以这条边界在实践中碰不到。
425
+ - **SSE 过滤会「认输」**:一个块迟迟不结束、或缓冲超过 1 MiB,就原样转发并停止改写这条连接(宁可漏拦,不能把实时流卡住)。
426
+ - **A 方案只挡「看得到」**:知道 sessionId 仍然能通过 `session/page` / `session/follow`(0.1.1 是 `session.history`)
427
+ 读别人的会话——那是 B 方案的事。
428
+ - **审批记忆是进程内的**:网关在 mux 上看到 `approval/request` 的 waterfall 帧后记住它的 eventId。
429
+ 网关重启期间若浏览器重连,宿主会重放未决的 waterfall(同一个 eventId),记忆随之重建;万一两条依据都没命中
430
+ (既没记住、值也不是 `allowed-once`),该次应答按原样放行——这是有意的取舍:把 `$events/result` 一律拒掉
431
+ 会连 `ask_user_question` 的答案一起吃掉。
432
+ - **整文件读取按「整文件」划界**:`readAll` / `readRelated` 受开关管,窗口化的 `readBytes` 不受管——反复调它理论上
433
+ 也能把文件搬走,但它是图片预览与分页阅读的底座,关掉会连文件浏览器一起关掉。
356
434
  - 上游 `@weibaohui/user-management`(0.9.5)与本包 `kczx-user-management` 是两个不同的包,互不覆盖,`pnpm update` 不会把两者串在一起。
357
435
 
358
436
  ## 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: '所属部门(可选,最多 64 字)',
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: '工作区路径',
@@ -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, max 64 chars)',
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',
@@ -607,12 +627,59 @@ 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
+ const DEPARTMENT_MAX_LEVEL_LEN = 24
638
+ const DEPARTMENT_MAX_LEN = 64
639
+
640
+ /**
641
+ * The levels of a department label.
642
+ *
643
+ * Client twin of the store's `departmentLevels`: both separators are accepted,
644
+ * every level is trimmed, empties are dropped. Tolerant on purpose — this side
645
+ * only previews and warns; the server's normalized answer is what gets stored.
646
+ */
647
+ function departmentLevels(value) {
648
+ return String(value === undefined || value === null ? '' : value)
649
+ .replace(/[\u0000-\u001f\u007f]/g, '')
650
+ .split(/[\\/]+/)
651
+ .map((level) => level.trim())
652
+ .filter((level) => level !== '')
653
+ }
654
+
655
+ /**
656
+ * The first thing wrong with a department label, or null.
657
+ *
658
+ * Mirrors the store's refusals so the admin sees the problem while typing
659
+ * instead of after a round trip. The server still validates — this is advice,
660
+ * not the gate.
661
+ */
662
+ function departmentProblem(levels, t) {
663
+ if (levels.length > DEPARTMENT_MAX_DEPTH) return interpolate(t('departmentTooDeep'), { n: DEPARTMENT_MAX_DEPTH })
664
+ for (const level of levels) {
665
+ if (level === '.' || level === '..') return t('departmentBadDot')
666
+ if (/[\\/:*?"<>|]/.test(level)) return t('departmentBadChar')
667
+ if (level.length > DEPARTMENT_MAX_LEVEL_LEN) {
668
+ return interpolate(t('departmentLevelTooLong'), { n: DEPARTMENT_MAX_LEVEL_LEN })
669
+ }
670
+ }
671
+ if (levels.join('/').length > DEPARTMENT_MAX_LEN) {
672
+ return interpolate(t('departmentTooLong'), { n: DEPARTMENT_MAX_LEN })
673
+ }
674
+ return null
675
+ }
676
+
610
677
  /**
611
678
  * The default workspace path, as the create-user dialog shows it:
612
- * `<root>/<department>/<username>`.
679
+ * `<root>/<一级>/<二级>/…/<username>`.
613
680
  *
614
681
  * This is the DISPLAY twin of the host half's rule (src/workspace-create.js):
615
- * the same folding of a free-text department into one directory name, joined
682
+ * one folder PER department level, each folded into a directory name, joined
616
683
  * with the separator the server reported. The server recomputes the path from
617
684
  * its own module on submit and its answer is the one that counts — the two must
618
685
  * agree, which is why the box shows the default rather than inventing one.
@@ -623,7 +690,8 @@ window.__ModuleLoader__.load({
623
690
  // showing a path relative to nothing.
624
691
  if (base === '') return ''
625
692
  const parts = [base]
626
- for (const raw of [department, username]) {
693
+ // The username is folded by the same rule as a level: it is one segment too.
694
+ for (const raw of [...departmentLevels(department), username]) {
627
695
  const segment = String(raw === undefined || raw === null ? '' : raw)
628
696
  .replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, '-')
629
697
  .replace(/-{2,}/g, '-')
@@ -634,6 +702,53 @@ window.__ModuleLoader__.load({
634
702
  return parts.join(sep || '/')
635
703
  }
636
704
 
705
+ /**
706
+ * The department box: one text field, its levels spelled out underneath, and the
707
+ * levels already in use as one-click prefixes.
708
+ *
709
+ * The stored value stays a single string — the tree lives in the '/' separators,
710
+ * which is what the workspace folders are built from — so the picker's job is
711
+ * only to make the next level cheap to type: click a known path and the field
712
+ * becomes `path/`, ready for the new level.
713
+ */
714
+ function DepartmentField({ id, value, onChange, suggestions, __t: t }) {
715
+ const levels = departmentLevels(value)
716
+ const problem = departmentProblem(levels, t)
717
+ const known = Array.isArray(suggestions) ? suggestions : []
718
+ return h('div', { className: 'um-dept' },
719
+ h('input', {
720
+ id,
721
+ className: 'um-input',
722
+ value,
723
+ maxLength: DEPARTMENT_MAX_LEN,
724
+ list: problem === null ? `${id}-known` : undefined,
725
+ placeholder: t('departmentPlaceholder'),
726
+ onChange: (e) => onChange(e.target.value),
727
+ }),
728
+ problem === null ? null : h('div', { className: 'um-dept-problem' }, problem),
729
+ levels.length === 0 ? null : h('div', { className: 'um-dept-levels' },
730
+ h('span', { className: 'um-muted' }, `${t('departmentLevelsLabel')}:`),
731
+ levels.map((level, index) => h('span', { key: `${index}-${level}`, className: 'um-dept-level' }, level))),
732
+ h('datalist', { id: `${id}-known` }, known.map((path) => h('option', { key: path, value: path }))),
733
+ known.length === 0
734
+ ? h('div', { className: 'um-muted um-dept-hint' }, t('departmentNoSuggestions'))
735
+ : h('div', { className: 'um-dept-hint' },
736
+ h('span', { className: 'um-muted' }, t('departmentPickHint')),
737
+ known.map((path) => h('button', {
738
+ key: path,
739
+ type: 'button',
740
+ className: 'um-dept-pick',
741
+ title: `${path}/`,
742
+ onClick: () => onChange(`${path}/`),
743
+ }, path))),
744
+ levels.length === 0 ? null : h('button', {
745
+ type: 'button',
746
+ className: 'um-dept-clear',
747
+ onClick: () => onChange(''),
748
+ }, t('departmentClear')),
749
+ )
750
+ }
751
+
637
752
  /** A blank create-user form. A factory, not a shared literal: closing the
638
753
  * dialog must hand back a fresh object, or React would keep rendering the
639
754
  * previous edit's values. */
@@ -737,6 +852,17 @@ window.__ModuleLoader__.load({
737
852
  .um-form { display: flex; flex-direction: column; gap: 8px; max-width: 340px; }
738
853
  .um-form label { font-size: 12px; color: var(--dsw-alias-label-secondary); }
739
854
  /* Create-user dialog: the optional workspace block (checkbox → path + hints). */
855
+ /* Department box: the field, its levels, and the known-level prefixes. */
856
+ .um-dept { display: flex; flex-direction: column; gap: 6px; }
857
+ .um-dept .um-input { width: 100%; }
858
+ .um-dept-problem { font-size: 11px; line-height: 1.5; color: var(--dsw-alias-state-error-primary); }
859
+ .um-dept-levels { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 11px; }
860
+ .um-dept-level { padding: 1px 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); }
861
+ .um-dept-level + .um-dept-level::before { content: '›'; margin-right: 4px; color: var(--dsw-alias-label-tertiary); }
862
+ .um-dept-hint { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 11px; line-height: 1.6; }
863
+ .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; }
864
+ .um-dept-pick:hover { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
865
+ .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
866
  .um-ws { display: flex; flex-direction: column; gap: 6px; }
741
867
  .um-ws .um-input { width: 100%; }
742
868
  .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 +1346,7 @@ window.__ModuleLoader__.load({
1220
1346
  * workspace (the whitelist denies by default, and '*' is the wide grant), an
1221
1347
  * empty limit means no cap — clearing a field is a visible, deliberate act
1222
1348
  * rather than a silent default. */
1223
- function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
1349
+ function EditUserPage({ user, me, departments, onClose, onDone, onTempPassword, __t: t }) {
1224
1350
  const p = user.permissions || {}
1225
1351
  const isSelf = user.id === me.id
1226
1352
  const [department, setDepartment] = useState(user.department || '')
@@ -1344,10 +1470,9 @@ window.__ModuleLoader__.load({
1344
1470
  h('label', { htmlFor: 'um-edit-username' }, t('colUser')),
1345
1471
  h('input', { id: 'um-edit-username', className: 'um-input', value: user.username, disabled: true }),
1346
1472
  h('label', { htmlFor: 'um-edit-department' }, t('colDepartment')),
1347
- h('input', {
1348
- id: 'um-edit-department', className: 'um-input', value: department, maxLength: 64,
1349
- placeholder: t('departmentPlaceholder'),
1350
- onChange: (e) => setDepartment(e.target.value),
1473
+ h(DepartmentField, {
1474
+ id: 'um-edit-department', value: department, onChange: setDepartment,
1475
+ suggestions: departments, __t: t,
1351
1476
  }),
1352
1477
  h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('editDepartmentHint')),
1353
1478
  h('label', { htmlFor: 'um-edit-role' }, t('colRolePick')),
@@ -1461,7 +1586,7 @@ window.__ModuleLoader__.load({
1461
1586
  * could not be read: the dialog then asks for an absolute path rather than
1462
1587
  * showing a path relative to nothing.
1463
1588
  */
1464
- function CreateUserDialog({ form, setForm, wsDefault, wsTouched, onTypePath, onResetPath, onClose, onSubmit, __t: t }) {
1589
+ function CreateUserDialog({ form, setForm, wsDefault, wsTouched, departments, onTypePath, onResetPath, onClose, onSubmit, __t: t }) {
1465
1590
  // Untouched means "the default" — shown, but sent to the server as blank so it
1466
1591
  // builds the path itself from its own root and its own sanitizing rules.
1467
1592
  const path = wsTouched ? form.workspacePath : wsDefault
@@ -1478,10 +1603,12 @@ window.__ModuleLoader__.load({
1478
1603
  h('label', { htmlFor: 'um-new-password' }, t('colPassword')),
1479
1604
  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
1605
  h('label', { htmlFor: 'um-new-department' }, t('colDepartment')),
1481
- h('input', {
1482
- id: 'um-new-department', className: 'um-input', value: form.department, maxLength: 64,
1483
- placeholder: t('departmentPlaceholder'),
1484
- onChange: (e) => setForm({ ...form, department: e.target.value }),
1606
+ h(DepartmentField, {
1607
+ id: 'um-new-department',
1608
+ value: form.department,
1609
+ onChange: (department) => setForm({ ...form, department }),
1610
+ suggestions: departments,
1611
+ __t: t,
1485
1612
  }),
1486
1613
  // The optional workspace: one checkbox, then the folder it will live in.
1487
1614
  h('label', { className: 'um-row', style: { gap: 6 } },
@@ -1523,6 +1650,10 @@ window.__ModuleLoader__.load({
1523
1650
  // reported it ({root, sep}); null until it answers. A path is not something to
1524
1651
  // guess at, so the dialog asks rather than assuming a layout.
1525
1652
  const [wsRoot, setWsRoot] = useState(null)
1653
+ // The department paths in use, for the level picker in both dialogs. Derived
1654
+ // from EVERY account, so it is fetched rather than scraped off the current
1655
+ // page of the paged roster.
1656
+ const [departments, setDepartments] = useState([])
1526
1657
  // Whether the admin typed in the path box. Untouched means "the default",
1527
1658
  // which is recomputed from the username/department as they are typed and left
1528
1659
  // for the server to build for real on submit.
@@ -1556,12 +1687,25 @@ window.__ModuleLoader__.load({
1556
1687
  */
1557
1688
  const openCreate = () => {
1558
1689
  setCreating(true)
1690
+ loadDepartments()
1559
1691
  if (wsRoot !== null) return
1560
1692
  api('/workspace-root')
1561
1693
  .then((d) => setWsRoot({ root: String(d.root || ''), sep: String(d.sep || '/') }))
1562
1694
  .catch(() => setWsRoot({ root: '', sep: '/' }))
1563
1695
  }
1564
1696
 
1697
+ /**
1698
+ * Refresh the known department paths.
1699
+ *
1700
+ * Called whenever a form that can change them opens or submits, so the picker
1701
+ * offers what the roster actually contains — a stale list would quietly drop
1702
+ * the level somebody just created.
1703
+ */
1704
+ const loadDepartments = () =>
1705
+ api('/departments')
1706
+ .then((d) => setDepartments(Array.isArray(d.departments) ? d.departments : []))
1707
+ .catch(() => setDepartments([]))
1708
+
1565
1709
  const closeCreate = () => {
1566
1710
  setCreating(false)
1567
1711
  setForm(NEW_USER_FORM())
@@ -1580,6 +1724,9 @@ window.__ModuleLoader__.load({
1580
1724
 
1581
1725
  const submitCreate = (e) => {
1582
1726
  e.preventDefault()
1727
+ // A new account may introduce a level; the picker should learn it without a
1728
+ // page reload.
1729
+ loadDepartments()
1583
1730
  // An untouched path box sends an EMPTY path: the server then builds the
1584
1731
  // default from its own root and its own sanitizing rules, so the folder can
1585
1732
  // never be a stale copy of what this form happened to display.
@@ -1640,7 +1787,10 @@ window.__ModuleLoader__.load({
1640
1787
  ? h('div', { className: 'um-muted', style: { fontSize: 11, whiteSpace: 'nowrap' } }, usageSummary(user, t))
1641
1788
  : null)),
1642
1789
  h('td', null, h('div', { className: 'um-row', style: { justifyContent: 'flex-end' } },
1643
- h('button', { className: 'um-btn um-btn-primary', onClick: () => setEditUser(user) }, t('actionEdit'))))))))),
1790
+ h('button', {
1791
+ className: 'um-btn um-btn-primary',
1792
+ onClick: () => { loadDepartments(); setEditUser(user) },
1793
+ }, t('actionEdit'))))))))),
1644
1794
  h(Pager, {
1645
1795
  page,
1646
1796
  pages: auditPageCount(total, pageSize),
@@ -1658,6 +1808,7 @@ window.__ModuleLoader__.load({
1658
1808
  // department and the username as they are typed); the dialog only draws it.
1659
1809
  wsDefault,
1660
1810
  wsTouched,
1811
+ departments,
1661
1812
  onTypePath: () => setWsTouched(true),
1662
1813
  onResetPath: () => setWsTouched(false),
1663
1814
  onClose: closeCreate,
@@ -1667,6 +1818,7 @@ window.__ModuleLoader__.load({
1667
1818
  editUser && h(EditUserPage, {
1668
1819
  user: editUser,
1669
1820
  me,
1821
+ departments,
1670
1822
  onClose: () => setEditUser(null),
1671
1823
  // The page closes on any write that changes the account, so the row behind
1672
1824
  // it is re-read instead of being reconciled field by field.
@@ -2742,7 +2894,7 @@ window.__ModuleLoader__.load({
2742
2894
  __internals: {
2743
2895
  NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, auditPageCount,
2744
2896
  AUDIT_PAGE_SIZES, AUDIT_PAGE_SIZE_DEFAULT, AuditTab, UsersTab, ActivityTab, Pager, avatarHue,
2745
- workspaceDefaultPath, NEW_USER_FORM, CreateUserDialog, ConsoleHead, ConsoleCorner, BackToSettings, ShellUserMenu,
2897
+ workspaceDefaultPath, departmentLevels, departmentProblem, DepartmentField, NEW_USER_FORM, CreateUserDialog, ConsoleHead, ConsoleCorner, BackToSettings, ShellUserMenu,
2746
2898
  setFullPageOverlay, fullPageOverlayOpen,
2747
2899
  UserAvatar, HeaderUserMenu, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, LogoutButton, doLogout,
2748
2900
  CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, MyPanel, EditUserPage, TotpCard, AuditTextCell,