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 时才需要
首次使用(初始化)
自助注册是关闭的:账号只能由管理员在设置页创建,第一个管理员走"初始化"流程。
启动 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自行指定(那样不会往磁盘写密钥文件)。浏览器打开
https://<主机>:8088/login—— 此时显示初始化页:填初始化密钥 + 用户名 + 密码。提交后该账号直接成为管理员并登录,初始化密钥当场销毁(删文件、清内存);此后
/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 目录下,
根目录因此不会被一堆部门目录铺满),可以直接改成任意绝对路径;下方一行小字写着默认值,
改过之后还有「恢复默认」。不勾选则一切照旧。
提交时服务端按这个顺序做事,能失败的都在写账号之前失败:
- 校验路径:自定义路径必须以盘符或
/开头(相对路径会报错,不会被悄悄拼到进程 cwd 上);根路径本身 (harness 目录)不能当工作区;路径长度 ≤ 1024。部门是管理员随手写的自由文本,所以它会被折成一段合法的目录名 (\/:*?"<>|与控制字符 →-,纯点号(..)直接丢掉),绝不允许它多出一层目录或跳出根路径。 - 归属冲突:这个路径已经属于别的账号 → 409,账号和目录都不会建。既有的目录(别人放的文件)会被领养而不是重新创建。
- 建目录(
mkdir -p,已存在则复用);失败则 400,不建账号。 - 建账号,并把该路径写进它的工作区白名单(
allowedFolders)。白名单是默认拒绝的:不放开的话,账号 根本看不到自己那块地。第 3 步建出来的空目录在账号写入失败时会被回收(只删空目录)。 - 登记进 dsh 工作区列表:通过宿主的
workspaceRegistry服务(运行期ctx.inject,不写死依赖)。 这一步失败不回滚——目录和账号都是真的,接口把registered: false和原因一起返回,界面提示 「用户已创建、目录已建好,但未登记进工作区列表」。dsh 的工作区列表用 realpath 后的规范路径,所以登记返回的 规范路径也会一并加进白名单(软链接根目录 / Windows 大小写差异下,账号才看得到自己的那块地)。 - 认领归属:把该路径记到这个账号名下——别人看不到、也不能删改(见「工作区归属跟踪」)。
接口: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(沿用上游许可)。