跳到主要内容

kczx-user-management

已验证

kczx-user-management · v1.6.3 · MIT · Web 界面

dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查(可一并创建工作区)、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。

安装

dsh plugin add kczx-user-management

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

发布到 npm 但没有公开仓库。安装前请检查包内容。

标签

作者

说明文档

kczx-user-management

本包是本地扩展版(1.0.0),已把 dsh-passwords 的账号 / 权限 / 配额 / 台账能力并入,不再是上游 0.9.5 的功能集。 上游同名包(npm 上的 @weibaohui/user-management)仅提供登录门禁与两步验证;本版额外提供工作区白名单与归属跟踪、新建账号即建工作区、 token 与时长配额、沙盒档位下限、上传 / git / 运维面门控、历史沙盒降级与隐藏字符清洗。 为便于从 registry 安装,本版以独立包名 kczx-user-management 发布,与上游互不覆盖。

给 dsh web 装一扇门:HTTPS 登录网关 + 账号管理 + 权限与配额执行 + 三本审计台账,一个插件完成。

安装

# 从 registry(发布之后;-w = pnpm 的 --workspace-root)
dsh plugin --profile web add kczx-user-management -w

# 从 tarball(离线 / 内网部署)
dsh plugin --profile web add ./kczx-user-management-1.0.0.tgz -w

# 或直接从目录 / 仓库(相对路径以你执行命令的目录为锚)
dsh plugin --profile web add link:E:\path\to\dsh-user-management -w
dsh plugin --profile web add github:you/dsh-user-management -w

# 装完重启 dsh web 生效

dsh plugin --profile <name> <args...> 就是在 ~/.dsh/profiles/<name> 里转发 pnpm <args...>;退出码 0 时按已安装状态 对账 dsh.profile.bundles——凡是声明了 dsh.bundle.patch 的依赖会自动进 layer 列表(本包含),所以不必手工改 bundles。

用 pnpm 直接安装

包内自带 dsh.bundle.patch,运行期依赖(@deepseek-ai/schemastery、bcryptjs、selfsigned)都在公共 registry 上,因此也可以当普通 npm 包装:

pnpm add kczx-user-management                      # 已发布到 registry 时
pnpm add ./kczx-user-management-1.0.0.tgz          # 从 tarball
pnpm add /abs/path/to/dsh-user-management          # 从目录

装进 dsh profile(把下面几行加进 ~/.dsh/profiles/web/package.json,再在该目录 pnpm install,然后重启 dsh web):

{
  "dependencies": { "kczx-user-management": "file:./kczx-user-management-1.0.0.tgz" },
  "dsh": { "profile": { "bundles": ["kczx-user-management"] } }
}

本包不依赖安装期脚本:postinstall 已移除(pnpm ≥10 默认拦截依赖构建脚本,留着只会让安装以 ERR_PNPM_IGNORED_BUILDS 退出码 1 收场),ensure:deps 保留为手动脚本,仅供「内网无 registry 且以 link: 挂载」的场景,从宿主补齐 @deepseek-ai/schemastery:

pnpm add ./kczx-user-management-1.0.0.tgz        # 正常路径:依赖从 registry 装齐
node scripts/ensure-deps.mjs                     # 仅当上述解析不到 schemastery 时才需要

首次使用(初始化)

自助注册是关闭的:账号只能由管理员在设置页创建,第一个管理员走"初始化"流程。

  1. 启动 dsh。库里还没有任何账号时,启动日志会打印一行初始化密钥:

    [user-management] first-run setup key 3f0c… (also in ~/.dsh/user-management/setup-key.txt)
    

    密钥同时写在 $DSH_HOME/user-management/setup-key.txt;也可以用 settings.yaml 的 user-management.setupKey 自行指定(那样不会往磁盘写密钥文件)。

  2. 浏览器打开 https://<主机>:8088/login —— 此时显示初始化页:填初始化密钥 + 用户名 + 密码。

  3. 提交后该账号直接成为管理员并登录,初始化密钥当场销毁(删文件、清内存);此后 /login 只显示登录表单, /user-management/api/setup 拒绝一切后续调用。

登录页样式与 dsh-passwords 一致(光晕背景 + 网格 + 玻璃卡片 + 渐变按钮),跟随宿主主题深浅色,右上角可切换中文 / English。 品牌可配:标题 / 副标题 / 页脚 / LOGO / 主色 / 默认语言都在 user-management: 段里,见「配置」一节。

它做什么

一扇门

独立的 node:https 监听器(默认 0.0.0.0:8088),自签证书(SAN 覆盖本机全部 IP + sslip.io / nip.io 别名), 未登录的页面访问跳转 /login、API 请求 401;登录页自带深浅色。设置页提供证书下载(PEM/DER)、指纹核对与 各系统导入向导。

账号与权限(管理员)

能力 说明
用户增删改查 用户表每行一个「编辑」全屏页:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮
所属部门(1.6.0 起支持多层级) 账号资料字段,支持多层级路径:用 / 分层,例如 和平分局/刑侦大队/一中队(\ 也认,存的时候统一成 /)。新增用户时可填,编辑页可随时改或清空;编辑时框下会把层级摊开显示,并列出**已在用的层级**供一键接续;变更写进操作日志。规则(服务端为准,界面同款提示在前面):最多 **5 级**、每一级 ≤ **24 字**、整串 ≤ **64 字**(含分隔符);层级里不能有 `\ / : * ? " < >
新建账号即建工作区 新增用户时勾选「同时创建该用户的工作区」:路径预填 <harness 根>/workspace/<部门各级>/<用户名>,一级部门一个目录(…\workspace\和平分局\刑侦大队\zhang;可改成任意绝对路径);提交后目录按需创建、登记进 dsh 工作区列表,并把该路径写进这个账号的工作区白名单。部门不合法时先报 400,再建目录——不会留下半棵树
工作区白名单 留空 = 不允许任何工作区(白名单默认拒绝);填写后只允许这些目录下的工作区;条目 * = 允许全部。Windows 路径折叠大小写,末尾斜杠自动忽略
工作区归属跟踪 谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理
会话可见性(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 的行保留——「判断不了」不等于「不该看」
每小时 token 配额 由浏览器上报 dsh 自身的用量投影增量,服务端按小时窗口累计并拦截
每日时长配额 按真实活跃时间累计(不是请求数),限流不因轮询膨胀
沙盒档位下限 read-only / workspace-write / danger-full-access:AI 无法通过设置、slash 命令或审批把自己提权
上传 / git 开关 上传与「把数据带走」的通道分别控制(git RPC、会话导出、SSH 下载等)
策略封禁 与「禁用」不同:执行层每次请求都读,封禁即刻踢出会话

三本台账

台账 内容
登录记录 登录 / 失败 / 登出 / 改密 / 初始化(首次建号)/ 建号 / 删号 / 角色变更 / 资料变更 / 权限变更(含紧凑差异串);按页浏览
访问记录 页面级访问(保留真实客户端 IP);按页浏览
操作日志 经过网关的每次 API 调用与 WebSocket 连接(方法 / 路径 / 状态 / 来源 IP);对话类请求连内容一起记:session.prompt 的提示词、session.rename 的会话标题写进同一行(最多 500 字,点开展开;粘贴的图片只记数量、不存 base64)。列表多一列「对话内容」,并按页浏览(默认每页 50,可选 20/50/100/200)

另有 IP 封禁(回环与当前 IP 拒绝封禁,防自锁)与 TOTP 两步验证(默认关闭,可选开启)。

分页:登录记录 / 访问记录 / 操作日志 / 用户表四张表都是服务端分页——查询参数带 limit + offset, 响应回 { ..., total },前端底部是共用的分页控件(上一页 / 第 X/Y 页 · 共 N 条 / 下一页 / 每页 20·50·100·200, 默认 50)。筛选同样在服务端执行,所以「共 N 条」永远是匹配总数而不是当前页条数。账号表只有在调用方 传了 limit 时才分页,不传仍然整份返回(后端脚本的老用法不受影响)。

dsh 版本适配(1.6.0 起)

dsh 有两代 API,本插件两代都认——这曾经是个坑:老代码只认第一代的路径,换到新宿主上不会报错, 只是那些请求永远匹配不上,于是三条强制悄悄失效。

0.1.1-rc.2(第一代) 0.1.2-rc.1+ / 0.1.5-rc.2(第二代)
一元 RPC POST /api/<ns>.<method> POST /api/<ns>/<method>
工作区列表 workspace.list 一元响应 workspace/follow 流(走 /api/remote.mux)
会话历史 session.history 一元响应 session/page 一元 + session/follow 的开场 snapshot
审批应答 POST /api/respond POST /api/$events/result(事件 id 由 mux 上的 waterfall 帧给出)
大文件外带 —— workspaceFiles/readAll、readRelated(整文件 base64)

因此在 0.1.2-rc.1 及更新的宿主上:

  • 工作区白名单 / 归属:过滤 workspace/follow 的 baseline / upsert / order 帧(别人的工作区不下发, 且 upsert 会被整帧丢弃,否则被过滤掉的条目会立刻长回来);
  • 沙盒档位下限:钳制 session/page 的 records、session/follow 的 snapshot 与逐条事件、 以及 session/control 上 permissions 投影的 currentValue;
  • 审批一律拒绝:把 $events/result 的 outcome 改写成 {kind:'result', value:'rejected'}。 判断「这是不是审批」有两条独立依据:mux 上 approval/request 的 waterfall 帧记下的 eventId, 以及 allowed-once(审批词表里唯一的放行值,问答的答案不可能是这个字符串);
  • 整文件读取:workspaceFiles/readAll|readRelated 归到「git / 下载」开关下。窗口化的 read/readBytes 与 stat/list 不拦——文件浏览器要靠它们画那一页,否则没开 git 的账号连文件都看不了。
  • 会话可见性:三个入口共用一条判定(见上表)。两代的一元返回值都是 { items: SessionSummary[] }, 但都包在连接信封里({ type:'server-response', rpcId, result:{ ok, value } })——只看顶层 items 在真流量上什么都匹配不到(这插件在对话内容台账上踩过一次,1.6.0 又踩了一次,所以 dsh-wire.js 现在带一个信封感知的定位器,测试也一律用真实信封而不是手写的裸形状)。 session.search 沿用同一份「刚被藏掉的 id」清单把命中片段一并去掉(列表是整份快照,每次响应 覆盖该清单而不是累加)。判定走 workspaceVisibility,与工作区列表同一套「白名单 + 归属」语义; 归属按包含该目录的最深认领算,所以 …/mty/src 里的会话算 …/mty 的。
  • 实时推送:0.1.1 走 src/sse-filter.js(逐 SSE 块解析,只丢 host/session-added 且判定为不可见的那种块, 其余字节原样放行);0.1.2+ 复用 mux 拦截器丢 api-session/added 帧。两条路都只在确认识别时动手, 认不出来就放行——这条流里跑着整个实时会话(含助手增量),绝不能因为它变哑。

宿主源码级的核对记录(每条形状的文件:行)在 docs/dsh-wire-notes.md。

配置

走 ~/.dsh/settings.yaml 的 user-management: 段。改完即时生效的字段无需重启;只有监听器字段 (listenHost / port / plaintext / sites)会触发监听器重建,其余一律下一个请求就用新值。

user-management:
  enabled: true
  listenHost: '0.0.0.0'   # 只本机可达就填 127.0.0.1
  port: 8088
  plaintext: false        # true 时只允许回环(明文服务观察层用,见 MERGED-DEPLOYMENT.md)
  sites: []               # 空 = 自动枚举本机 IP;配域名 + 证书则按 SNI 选择
  setupKey: ''            # 首次初始化密钥;留空 = 启动时随机生成并写到 setup-key.txt
  historyMaxBytes: 67108864   # 历史改写缓冲上限(字节,默认 64 MiB,最小 1 MiB)

  # ── 登录页品牌(全部即时生效,改完刷新 /login 即可) ──
  title: 'DSH 控制台'      # 部署名:标签页标题后缀 + 标题兜底
  brandName: ''           # 卡片主标题;只填它,门面就是你的产品名
  loginTitle: ''          # 逐行覆盖内置文案('' = 用内置的「登录」/ Sign in)
  loginSub: ''
  setupTitle: ''          # 同上,作用于首次「初始化平台」表单
  setupSub: ''
  footerText: ''          # '' = 内置的「dsh 插件 · user-management」
  showFooter: true
  logoUrl: ''             # http(s)、data: URI,或本机图片文件;'' = 内置标记
  logoSize: 48            # 标记方框边长(px,24–96);图片按 contain 铺满方框
  auditConversationText: true   # 操作日志记录 session.prompt 文字与会话标题(见下)
  brandColor: ''          # '#rrggbb';只重染品牌色 token,danger/ok/warn 不动
  defaultLang: 'zh'       # 不带 ?lang= 时的门面语言(zh | en)
  showLangSwitch: true    # 是否显示页内中英切换

  # ── 会话与请求策略(全部即时生效) ──
  sessionDays: 7          # 会话有效期(天,滑动续期:过半后续一次)
  maxBodyBytes: 65536     # 本插件 API 的 JSON 请求体上限(字节)
  workspaceRoot: ''       # 新建账号工作区的根路径;'' = 问宿主(dsh 的工作区根,默认即 harness 所在目录)
  otpFailLimit: 5         # 两步验证连续错误几次后锁定
  otpLockoutSeconds: 60   # 锁定多久

登录页的品牌只吃 brandName / loginTitle 这类字段,改名不会影响账号与台账;brandColor 只重染 品牌色那几个 token(按钮、LOGO 底、聚焦环及其阴影),状态色保持不变——颜色承载语义,不该被顺手改掉。

LOGO 支持本机图片(1.1.2 起):logoUrl 填绝对路径(E:/brand/logo.png)或 $DSH_HOME/user-management/ 下的文件名(把图片放到 users.json 旁边,然后填 logo.png)。裸路径本身不是 URL,浏览器会拿它当相对路径去 请求网关,所以本机图片由插件用 /user-management/branding/logo 这条公开路由回吐给登录页(和 /login 一样 必须免登录,否则图片是 401);该路由只吐这一个配置文件,且限定图片扩展名、非空、≤ 512 KiB,其余一律 404, 路径下的其它子路径在本机就直接 404,不会被匿名转发到 dsh 上游。改图不用重启——路由每次现读文件。

图片铺满方框:logoSize 控制方框边长(默认 48px,24–96),图片用 object-fit: contain 填进去——方图正好 铺满,长图/竖图留白而不裁切。这是品牌字段里唯一会改布局的一个,调大小在设置页里改完刷新即可,不用重启。 未接线的占位字段仍有 loginFailLimit / lockoutSeconds(登录失败不自动锁定,用 IP 封禁代替), 保留只是为了老的 settings.yaml 继续可解析。

操作日志的分页(1.3.0 起)

台账上限 5000 行、每行还带对话文字,一次全拉进浏览器既慢又没必要。现在「操作日志」是服务端分页: GET /user-management/api/audit?limit=50&offset=100 返回 { entries, total, limit, offset }——除了这一页, 还给出匹配总数,前端才说得出「第 3/40 页 · 共 1987 条」。表格下方有上一页 / 下一页和每页条数(20/50/100/200)。

两个容易做错的点,都按服务端口径处理:

  • 筛选也走服务端,和分页窗口是同一组参数:username / method / path / statusClass。若在前端过滤已取回的 那一页,翻页翻的就是"某个结果的子集",而且"共 N 条"只能是这一页的数字——那是错的。筛选条件改动会回到第 1 页 (换条件后停在旧页码没有意义),输入框有 250ms 去抖,不会每敲一个字发一次请求。
  • 删掉最后一条时若当前页只剩那一行,会退回上一页,而不是停在"第 N 页(空的)"。

操作日志里的对话内容(1.2.0 起)

「用户管理 → 操作日志」每一行是经过网关的一次 API 调用。以前只看得到 POST /api/session.prompt——可这一行不论用户说了什么都长一个样, 日志于是回答不了「这个账号到底做了什么」。现在网关把人打的那句话从请求体里取出来,随行写进台账:

  • session.prompt → content[] 里所有 type: 'text' 片段按行拼接;type: 'image' 只记数量(截图是 base64,不是台账该存的东西)。
  • session.rename → 会话标题。
  • 其它 RPC 完全不碰请求体,仍按原来的方式流式转发。
  • 存储侧每行最多 500 字(MAX_AUDIT_TEXT,超长截断);前端那一列默认单行省略、点一下就地展开全文,图片数量显示为徽标。
  • 只观察、不拦截:抓取只是顺路把请求体缓一下再原样重放给上游,字节不变;超过 2 MiB 的请求体(例如带大图的提示词)原样透传、不记录文字。
  • 隐私开关:user-management.auditConversationText: false(设置页「会话与请求策略」里也有这一项)关掉即完全不抓。台账是 $DSH_HOME/user-management/audit.jsonl 明文文件,提示词可能含口令 / 密钥时请关闭。

部门层级(1.6.0 起)

部门的「多层级」不是一张独立的部门表,而是一个字符串里的路径:和平分局/刑侦大队/一中队。这么定有两个好处——老数据不用迁移(原来的 平台组 就是单级),而且它正好就是工作区目录的形状:勾了「同时创建该用户的工作区」的账号,目录会一级一个文件夹地铺开 (都挂在 <harness 根>/workspace/ 这一层下面)。

规则 值
层级分隔符 /(\ 也接受,存成 /)
最多层级 5
每一级最长 24 字
整串最长 64 字(含分隔符)
层级里禁止 `\ / : * ? " < >

空层级会被丢掉,两端空格会被裁掉:" 和平分局 / 刑侦大队 // 一中队 " 存成 和平分局/刑侦大队/一中队。不合法就报错,不会把非法字符悄悄折成 -:这条标签同时也是目录名,标签和目录不一致比报错更糟。

编辑页与新建对话框里,部门框下面是层级摊开预览(和平分局 › 刑侦大队)、已在用层级的快捷前缀(点一下就把框变成 该路径/,接着往下敲一级即可),以及和上面同一套规则的即时校验提示。已用层级来自 GET /user-management/api/departments(管理员专用,返回所有前缀,不只是叶子节点——有人正好挂在中间层时,父级不能消失)。

新建用户时一并创建工作区(1.5.0 起)

「用户 → 新增用户」多了一个复选框「同时创建该用户的工作区」。勾上后出现路径框,预填默认路径 <harness 根>/workspace/<部门各级>/<用户名>(部门留空就少一层,只到用户名;所有账号都在同一个 workspace 目录下, 根目录因此不会被一堆部门目录铺满),可以直接改成任意绝对路径;下方一行小字写着默认值, 改过之后还有「恢复默认」。不勾选则一切照旧。

提交时服务端按这个顺序做事,能失败的都在写账号之前失败:

  1. 校验路径:自定义路径必须以盘符或 / 开头(相对路径会报错,不会被悄悄拼到进程 cwd 上);根路径本身 (harness 目录)不能当工作区;路径长度 ≤ 1024。部门是管理员随手写的自由文本,所以它会被折成一段合法的目录名 (\ / : * ? " < > | 与控制字符 → -,纯点号(..)直接丢掉),绝不允许它多出一层目录或跳出根路径。
  2. 归属冲突:这个路径已经属于别的账号 → 409,账号和目录都不会建。既有的目录(别人放的文件)会被领养而不是重新创建。
  3. 建目录(mkdir -p,已存在则复用);失败则 400,不建账号。
  4. 建账号,并把该路径写进它的工作区白名单(allowedFolders)。白名单是默认拒绝的:不放开的话,账号 根本看不到自己那块地。第 3 步建出来的空目录在账号写入失败时会被回收(只删空目录)。
  5. 登记进 dsh 工作区列表:通过宿主的 workspaceRegistry 服务(运行期 ctx.inject,不写死依赖)。 这一步失败不回滚——目录和账号都是真的,接口把 registered: false 和原因一起返回,界面提示 「用户已创建、目录已建好,但未登记进工作区列表」。dsh 的工作区列表用 realpath 后的规范路径,所以登记返回的 规范路径也会一并加进白名单(软链接根目录 / Windows 大小写差异下,账号才看得到自己的那块地)。
  6. 认领归属:把该路径记到这个账号名下——别人看不到、也不能删改(见「工作区归属跟踪」)。

接口:POST /user-management/api/users 增加 createWorkspace: true 与可选的 workspacePath(留空 = 服务端按默认规则生成), 响应多一个 workspace 报告({ ok, path, source, directory, registered, owner, error },没勾选时为 null); GET /user-management/api/workspace-root(管理员)回 { root, sep },新增用户弹窗用它预填路径。 根路径优先取 user-management.workspaceRoot,未配置则问宿主的 sandboxPolicy.workspaceRoot(dsh 默认就是进程 cwd, 即 harness 所在目录),最后兜底 process.cwd()。

被拒绝时提示什么(1.5.1 起)

被网关拦下的请求(没有建工作区的权限、目录不在白名单、配额用完、上传 / git 被关、沙盒提权被拒……)过去一律回 HTTP 403。 而 dsh 的浏览器客户端把任何非 2xx 都当成传输故障、根本不读响应体,于是用户看到的是:

workspace create failed: internal: transport failure for /api/workspace.create: HTTP 403

这读起来像程序坏了,而不像「你的账号没有这个权限」。现在这类拒绝改走 RPC 协议自带的错误位:HTTP 200 + result.ok: false, 原因写在 error.message 里,各处 dsh 界面都会原样显示:

workspace create failed: internal: 本账号没有创建 / 管理工作区的权限(请联系管理员在「用户管理 → 编辑用户 → 权限与配额」中开启「允许创建工作区」)

只有不是 RPC 调用的请求(没有 rpcId 可回显的裸请求、GET)仍回原来的 403 JSON —— 给它们编一个 rpcId 只会把「被拒绝」 变成客户端的协议错误,比原样回报更糟。

顺带修好一个盲区:被拒绝的请求以前完全不进操作日志(它没到上游,写日志的钩子永远不会触发)。现在会记一行 403 + 已拒绝:<原因>,管理员在「用户管理 → 操作日志」里直接看得到谁、想做什么、为什么被挡下来。

控制台页头的位置(1.5.3 起)

「用户管理」整页原来的页头是一条横贯屏幕的标题栏:宽屏上标题孤零零贴在左上角、按钮贴在右上角,和下面的 tab 条与内容列毫无关系。现在标题与 tab 收进居中内容列,而「退出登录」单独钉在窗口的右上角:

[⏻ 退出登录]                                  ← 贴窗口右上角(与「编辑用户」页那颗同一位置)
     用户管理                                  ← 内容列内,与 tab 条左对齐
     [用户][登录记录][访问记录][操作日志][IP 封禁][证书][2FA]  [返回设置]
     …表格 / 台账…

「用户管理」这一行是「这一页是什么」,与内容左对齐;「返回设置」与 tab 条同一条线、右边缘对齐。退出登录之所以 不跟着内容列走,是因为上一层「编辑用户」页把自己的退出登录放在窗口右上角——同一个按钮不能因为进出一层就横向挪位。 普通用户没有 tab 条,但「返回设置」仍然落在同一条线上(右对齐),两个视图因此读起来还是同一页。观察者模式 (dsh-passwords 在前)没有退出登录,只有「返回设置」。

「退出登录」现在先问一句(确定退出登录?退出后需要重新输入用户名和密码。):控制台页头和账号菜单里各有一个入口, 且都离用户正在做的事只差一次误点——确认放在共用的 doLogout() 里,谁都绕不过去。

「编辑用户」整页自带一个退出登录,钉在页面最右上角(关闭 / 保存之后隔一条竖线,不跟表单按钮混在一起):

┌ 编辑用户 · bob ─────────────────────────────────────────────┐
│                             [关闭] [保存] │ [⏻ 退出登录]    │
└─────────────────────────────────────────────────────────────┘

同一时刻屏幕上只有一个退出登录:打开这一页时,控制台那个窗口右上角的退出登录收起来(位置本来就是同一个,只是避免两颗同时存在)。不透明浮层确实盖住了它, 但「盖住」不等于「不在文档里」——那时它离一次 Tab+Enter 只有一步,而这颗按钮结束的是整个会话。收放由一个模块级的 小订阅(setFullPageOverlay)广播,编辑页挂载时置位、卸载时清除。

欢迎页右上角的账号信息(1.5.4 起)

会话头部只在有会话时才存在,于是落地页(欢迎页 / 新建会话页)成了唯一一个不说「你是谁」的页面——连账号菜单里 仅有的两件事(改密码、退出登录)也没地方点。现在账号芯片(头像 + 用户名,点开即账号菜单)还会落在shell 的全局 overlay 座位(ui-layout 的 shell.overlay,一个可叠加的 list 槽)上,固定在窗口右上角;一旦有会话成为当前会话, 这个芯片立刻消失,把位置让回会话头部那一个,不会出现两个。

实现上用的是该座位给每个全局占用者注入的 useSessions:state.current 就是框架自己对「现在有没有打开会话」的回答, 不需要去偷看 DOM。hero 上的三个座位(品牌标记 / 工作区选择器 / 代理预设)都是单占位且已被占用,所以走 overlay 座位。

在设置页里改(1.1.0 起)

上面这些字段也可以在 设置 → 通用设置 里改,就在「语言」「外观」那几行下面:「登录页与策略配置」一行, 默认收起着(通用列里都是紧凑的单行偏好,十六个字段全展开会把这一页压垮),行内摘要写着登录页当前显示 什么、有几项被改过,点「展开」出现完整表单。本插件在浏览器侧把这个设置命名空间绑成 settingsScope.bind({ namespace: 'user-management' }),并注册进 settings.general.item 槽——宿主注册 命名空间、浏览器注册控件,两边经由同一份 settings 文档同步。

表单行为:改过没存的字段标「未保存」;写入过 settings.yaml 的字段标「已自定义」并带「恢复默认」 (unset,清掉覆盖回到内置值,也让 settings.yaml 保持干净);文本框留空等于清除覆盖;数字字段按 schema 的下限校验(比如请求体上限 ≥ 1024);保存按字段顺序逐个写入,任何一个值非法就整体不落盘并提示是哪一项。 远程浏览器访问时设置是只读的(settings RPC 只走回环),这一行会直接说明并让你改服务器上的 settings.yaml; 装了旧版插件时它也会明说「这些新字段保存了也不会生效」。

刻意没放进设置页的字段:listenHost / port / plaintext / sites / enabled 会重启监听器——那正是承载 这个设置页的东西,从 UI 里翻可能把自己关在门外;trustedSecret / setupKey 是密钥,不该经浏览器表单往返。 这些继续只在 settings.yaml 里配。

数据文件

都在 $DSH_HOME/user-management/(默认 ~/.dsh/user-management/,0600,原子写):

文件 内容
users.json 账号(scrypt 哈希;迁移进来的账号保留 bcrypt,首次登录成功后自动升级为 scrypt;含 department 所属部门)
sessions.json 会话令牌(重启不掉线,7 天滑动过期)
activity.jsonl / audit.jsonl 登录与访问台账 / 操作日志(滚动保留最近若干条)
bans.json IP 封禁
usage.json 按账号的当日活跃秒数与小时窗口 token(隔日自动清理)
workspaces.json 工作区归属(路径 → 账号)

从 dsh-passwords 迁移

账号、权限与配额可由 scripts/import-from-dsh-passwords.mjs 一次性导入(读取源库时复用 dsh-passwords 自己的模块, 因为它的用户名是 AES-GCM 密文 + HMAC 索引):

node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --dry-run
node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords
# 同名账号两边密码不同时,以源端为准(迁移期间通常正是你要的)
node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --overwrite-credentials

迁移是幂等的:重复运行只会更新权限,不会重复建号;默认不覆盖已有账号的密码。

测试

npm test          # 248 项:策略纯函数、store、API 层、客户端契约、以及真实网关的端到端用例
npm run check     # 全量语法检查

端到端用例会起真实网关(明文模式 + 假上游)验证:工作区过滤含 gzip、目录门与请求体重放、沙盒提权拦截与审批改写、 上传 / git / 运维面门控、历史降级与隐藏字符清洗、配额拦截、归属认领与释放。

test/dsh-0.1.5-wire.test.mjs 另外覆盖第二代 wire:假的 dsh 上游在 /api/remote.mux 上做真实的 WebSocket 握手,按 streamId 收发 JSON 文本帧,验证 workspace/follow 的 baseline / upsert / order 过滤、 session/follow 的 snapshot 与逐条事件钳制、$events waterfall 记录 + $events/result 审批拒绝、 session/page 改写、session/control 的权限投影钳制、以及 workspaceFiles/readAll 门控; 纯函数部分(dsh-wire.js 的帧计划、mux-filter.js 的编解码)单独覆盖丢弃 / 透传 / 分片放行语义。

test/session-visibility.test.mjs 覆盖会话可见性:越界 cwd 的行被摘掉、没 cwd 与子代理行保留、 别人认领目录(含其子目录)里的会话被摘掉(blank 也照摘)、自己认领的共享目录不摘、管理员原样、 空白名单全摘、session.search 跟随列表的隐藏清单、以及 SSE 那一路——假上游按真实 data: {…}\n\n 写 host/session-added,且故意从块中间切成两个 chunk 写出来,验证缓冲与逐块判定。 所有响应都用真实信封形状,裸 { items } 只作为兼容形状另测一次。

卸载 / 回滚

dsh plugin --profile web remove kczx-user-management

数据文件不会被删除,重新装回即恢复。若在合并部署中回退到「观察层」形态,见 MERGED-DEPLOYMENT.md。

已知边界

  • 执行只对本地存在的账号生效:签名身份 / 未知用户名按「不限制」处理,避免迁移期误锁人。
  • 门只管走门的人:若另有插件把 dsh 宿主绑到 0.0.0.0(例如 dsh-lan-access),直连宿主端口即可绕过登录, 且绕过流量不会出现在任何一本台账里。要真正的强制约束,宿主端口必须保持回环。
  • 工作区白名单的语义:空名单 = 全部禁止(1.0.4 起;此前是"允许全部")。升级时会对老记录做一次性迁移——把"空名单"写成显式 的 *(允许全部),保持账号原有的可达范围;迁移只跑一次(留下 workspace-scope-migration.json 标记),之后你手工清空白名单 才等于全部禁止。新建账号默认空 = 无工作区权限。
  • token 配额依赖客户端上报:浏览器侧被禁用或页面未加载时不计费(时长配额不受影响,它在服务端计时)。
  • 会话历史要缓冲后才能改写:历史响应是一个 JSON 文档(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符 清洗,因此有 historyMaxBytes 上限(默认 64 MiB)。超过上限的会话会 fail-closed 返回 502,UI 显示「历史加载失败」—— 调大该值即可;1.0.0 之前用代理默认的 8 MiB,长会话必然踩中。
  • 第二代 wire 的强制有两条前提:一是浏览器走本网关(直连宿主端口没有任何强制,见上),二是帧是 单帧 JSON 文本。分片的 WebSocket 消息、二进制帧、以及 ping/pong 控制帧一律原样放行——为了一个分片帧去缓冲 别人整条流不划算;ws 宿主与浏览器发这些控制消息都不分片,所以这条边界在实践中碰不到。
  • SSE 过滤会「认输」:一个块迟迟不结束、或缓冲超过 1 MiB,就原样转发并停止改写这条连接(宁可漏拦,不能把实时流卡住)。
  • A 方案只挡「看得到」:知道 sessionId 仍然能通过 session/page / session/follow(0.1.1 是 session.history) 读别人的会话——那是 B 方案的事。
  • 审批记忆是进程内的:网关在 mux 上看到 approval/request 的 waterfall 帧后记住它的 eventId。 网关重启期间若浏览器重连,宿主会重放未决的 waterfall(同一个 eventId),记忆随之重建;万一两条依据都没命中 (既没记住、值也不是 allowed-once),该次应答按原样放行——这是有意的取舍:把 $events/result 一律拒掉 会连 ask_user_question 的答案一起吃掉。
  • 整文件读取按「整文件」划界:readAll / readRelated 受开关管,窗口化的 readBytes 不受管——反复调它理论上 也能把文件搬走,但它是图片预览与分页阅读的底座,关掉会连文件浏览器一起关掉。
  • 上游 @weibaohui/user-management(0.9.5)与本包 kczx-user-management 是两个不同的包,互不覆盖,pnpm update 不会把两者串在一起。

License

MIT(沿用上游许可)。