kczx-user-management
Đã xác minhkczx-user-management · v1.6.3 · MIT · Giao diện web
dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查(可一并创建工作区)、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。
Cài đặt
dsh plugin add kczx-user-management Xác nhận layer đã áp bằng dsh --profile default --dump-config — xem hướng dẫn cài plugin.
Mã nguồn
Phát hành lên npm mà không có repository công khai. Hãy kiểm tra nội dung package trước khi cài.
Thẻ
Tác giả
Readme
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(沿用上游许可)。