kczx-user-management 1.0.2 → 1.0.4
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/CUTOVER-RUNBOOK.md +2 -2
- package/MERGED-DEPLOYMENT.md +2 -2
- package/README.md +27 -5
- package/client/bundle.js +70 -55
- package/client/index.js +70 -55
- package/docs/FAQ.md +4 -4
- package/package.json +1 -1
- package/src/certs.js +43 -10
- package/src/gateway-core.js +16 -3
- package/src/index.js +94 -39
- package/src/login-page.js +392 -145
- package/src/permissions.js +34 -11
- package/src/setup-key.js +117 -0
- package/src/store.js +31 -3
package/CUTOVER-RUNBOOK.md
CHANGED
|
@@ -39,7 +39,7 @@ takes a few minutes and the rollback takes one command.
|
|
|
39
39
|
# user-management:
|
|
40
40
|
# listenHost: '0.0.0.0' # was 127.0.0.1 (observer mode)
|
|
41
41
|
# plaintext: false # was true: the door terminates TLS again
|
|
42
|
-
# port:
|
|
42
|
+
# port: 8088
|
|
43
43
|
# trustedSecret: '' # no signing gateway in front of it any more
|
|
44
44
|
|
|
45
45
|
# 2. remove dsh-passwords from the profile
|
|
@@ -50,7 +50,7 @@ dsh plugin --profile web remove dsh-passwords
|
|
|
50
50
|
|
|
51
51
|
## Verification after the switch
|
|
52
52
|
|
|
53
|
-
1. `https://<host>:
|
|
53
|
+
1. `https://<host>:8088/login` renders, a wrong password is refused, a correct
|
|
54
54
|
one lands on the SPA.
|
|
55
55
|
2. The settings page shows 用户管理 with the accounts that were migrated
|
|
56
56
|
(`mxz`, `mxz1`) and their permissions intact.
|
package/MERGED-DEPLOYMENT.md
CHANGED
|
@@ -43,7 +43,7 @@ dsh 宿主 127.0.0.1:3080
|
|
|
43
43
|
user-management:
|
|
44
44
|
enabled: true
|
|
45
45
|
listenHost: '127.0.0.1' # 明文模式会强制为回环,配别的会被拒绝并告警
|
|
46
|
-
port:
|
|
46
|
+
port: 8088
|
|
47
47
|
plaintext: true # 明文监听:登录门的 WS 升级走裸 TCP 隧道,无法对这一跳做 TLS
|
|
48
48
|
trustedSecret: '<64 hex>' # 与 dsh-passwords 的 MCP_IDENTITY_SECRET 必须逐字一致
|
|
49
49
|
# trustedPeers: [] # 默认即回环;仅在确有需要时覆盖
|
|
@@ -53,7 +53,7 @@ user-management:
|
|
|
53
53
|
|
|
54
54
|
```
|
|
55
55
|
MCP_IDENTITY_SECRET=<同一个 64 hex> # 专用密钥,不复用 MCP_INTERNAL_SECRET
|
|
56
|
-
MCP_GATEWAY_UPSTREAM=http://127.0.0.1:
|
|
56
|
+
MCP_GATEWAY_UPSTREAM=http://127.0.0.1:8088
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
两端密钥不一致是**静默失败**——观察层会拒绝全部请求,台账停止增长而没有任何报错。
|
package/README.md
CHANGED
|
@@ -54,11 +54,30 @@ pnpm add ./kczx-user-management-1.0.0.tgz # 正常路径:依赖从 regi
|
|
|
54
54
|
node scripts/ensure-deps.mjs # 仅当上述解析不到 schemastery 时才需要
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## 首次使用(初始化)
|
|
58
|
+
|
|
59
|
+
**自助注册是关闭的**:账号只能由管理员在设置页创建,第一个管理员走"初始化"流程。
|
|
60
|
+
|
|
61
|
+
1. 启动 dsh。库里还没有任何账号时,启动日志会打印一行初始化密钥:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
[user-management] first-run setup key 3f0c… (also in ~/.dsh/user-management/setup-key.txt)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
密钥同时写在 `$DSH_HOME/user-management/setup-key.txt`;也可以用 `settings.yaml` 的
|
|
68
|
+
`user-management.setupKey` 自行指定(那样不会往磁盘写密钥文件)。
|
|
69
|
+
|
|
70
|
+
2. 浏览器打开 `https://<主机>:8088/login` —— 此时显示**初始化页**:填初始化密钥 + 用户名 + 密码。
|
|
71
|
+
3. 提交后该账号**直接成为管理员并登录**,初始化密钥当场销毁(删文件、清内存);此后 `/login` 只显示登录表单,
|
|
72
|
+
`/user-management/api/setup` 拒绝一切后续调用。
|
|
73
|
+
|
|
74
|
+
> 登录页样式与 dsh-passwords 一致(光晕背景 + 网格 + 玻璃卡片 + 渐变按钮),跟随宿主主题深浅色,右上角可切换中文 / English。
|
|
75
|
+
|
|
57
76
|
## 它做什么
|
|
58
77
|
|
|
59
78
|
### 一扇门
|
|
60
79
|
|
|
61
|
-
独立的 `node:https` 监听器(默认 `0.0.0.0:
|
|
80
|
+
独立的 `node:https` 监听器(默认 `0.0.0.0:8088`),自签证书(SAN 覆盖本机全部 IP + sslip.io / nip.io 别名),
|
|
62
81
|
未登录的页面访问跳转 `/login`、API 请求 401;登录页自带深浅色。设置页提供证书下载(PEM/DER)、指纹核对与
|
|
63
82
|
各系统导入向导。
|
|
64
83
|
|
|
@@ -68,7 +87,7 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
|
|
|
68
87
|
|---|---|
|
|
69
88
|
| 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
|
|
70
89
|
| 所属部门 | 账号资料字段(trim 后 ≤ 64 字,控制字符剔除):新增用户时可填,编辑页可随时改或清空;变更写进操作日志 |
|
|
71
|
-
| 工作区白名单 |
|
|
90
|
+
| 工作区白名单 | **留空 = 不允许任何工作区**(白名单默认拒绝);填写后只允许这些目录下的工作区;条目 `*` = 允许全部。Windows 路径折叠大小写,末尾斜杠自动忽略 |
|
|
72
91
|
| 工作区归属跟踪 | 谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理 |
|
|
73
92
|
| 每小时 token 配额 | 由浏览器上报 dsh 自身的用量投影增量,服务端按小时窗口累计并拦截 |
|
|
74
93
|
| 每日时长配额 | 按真实活跃时间累计(不是请求数),限流不因轮询膨胀 |
|
|
@@ -80,7 +99,7 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
|
|
|
80
99
|
|
|
81
100
|
| 台账 | 内容 |
|
|
82
101
|
|---|---|
|
|
83
|
-
| 登录记录 | 登录 / 失败 / 登出 / 改密 /
|
|
102
|
+
| 登录记录 | 登录 / 失败 / 登出 / 改密 / 初始化(首次建号)/ 建号 / 删号 / 角色变更 / 资料变更 / 权限变更(含紧凑差异串) |
|
|
84
103
|
| 访问记录 | 页面级访问(保留真实客户端 IP) |
|
|
85
104
|
| 操作日志 | 经过网关的每次 API 调用与 WebSocket 连接(方法 / 路径 / 状态 / 来源 IP) |
|
|
86
105
|
|
|
@@ -94,11 +113,11 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
|
|
|
94
113
|
user-management:
|
|
95
114
|
enabled: true
|
|
96
115
|
listenHost: '0.0.0.0' # 只本机可达就填 127.0.0.1
|
|
97
|
-
port:
|
|
116
|
+
port: 8088
|
|
98
117
|
plaintext: false # true 时只允许回环(明文服务观察层用,见 MERGED-DEPLOYMENT.md)
|
|
99
118
|
sites: [] # 空 = 自动枚举本机 IP;配域名 + 证书则按 SNI 选择
|
|
100
119
|
title: 'DSH 控制台'
|
|
101
|
-
|
|
120
|
+
setupKey: '' # 首次初始化密钥;留空 = 启动时随机生成并写到 setup-key.txt
|
|
102
121
|
historyMaxBytes: 67108864 # 历史改写缓冲上限(字节,默认 64 MiB,最小 1 MiB)
|
|
103
122
|
```
|
|
104
123
|
|
|
@@ -152,6 +171,9 @@ dsh plugin --profile web remove kczx-user-management
|
|
|
152
171
|
- **执行只对本地存在的账号生效**:签名身份 / 未知用户名按「不限制」处理,避免迁移期误锁人。
|
|
153
172
|
- **门只管走门的人**:若另有插件把 dsh 宿主绑到 `0.0.0.0`(例如 dsh-lan-access),直连宿主端口即可绕过登录,
|
|
154
173
|
且**绕过流量不会出现在任何一本台账里**。要真正的强制约束,宿主端口必须保持回环。
|
|
174
|
+
- **工作区白名单的语义**:空名单 = 全部禁止(1.0.4 起;此前是"允许全部")。升级时会对老记录做**一次性迁移**——把"空名单"写成显式
|
|
175
|
+
的 `*`(允许全部),保持账号原有的可达范围;迁移只跑一次(留下 `workspace-scope-migration.json` 标记),之后你手工清空白名单
|
|
176
|
+
才等于全部禁止。新建账号默认空 = 无工作区权限。
|
|
155
177
|
- **token 配额依赖客户端上报**:浏览器侧被禁用或页面未加载时不计费(时长配额不受影响,它在服务端计时)。
|
|
156
178
|
- **会话历史要缓冲后才能改写**:历史响应是**一个 JSON 文档**(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符
|
|
157
179
|
清洗,因此有 `historyMaxBytes` 上限(默认 64 MiB)。超过上限的会话会 **fail-closed 返回 502**,UI 显示「历史加载失败」——
|
package/client/bundle.js
CHANGED
|
@@ -199,8 +199,11 @@ window.__ModuleLoader__.load({
|
|
|
199
199
|
usageTokensFree: '{used} tok/h',
|
|
200
200
|
permissionsTitle: '权限与配额',
|
|
201
201
|
permFolders: '工作区白名单',
|
|
202
|
-
permFoldersHint: '留空 =
|
|
203
|
-
|
|
202
|
+
permFoldersHint: '留空 = 不允许任何工作区;填写后只允许这些目录下的工作区;条目 * 表示允许全部。',
|
|
203
|
+
permFoldersNone: '未授权(不允许任何工作区)',
|
|
204
|
+
permFoldersAll: '允许全部(*)',
|
|
205
|
+
permFoldersAllShort: '允许全部工作区',
|
|
206
|
+
permFoldersNoneShort: '禁止所有工作区',
|
|
204
207
|
permFolderPlaceholder: '例如 /srv/work 或 D:\\work',
|
|
205
208
|
add: '添加',
|
|
206
209
|
remove: '移除',
|
|
@@ -382,8 +385,11 @@ window.__ModuleLoader__.load({
|
|
|
382
385
|
usageTokensFree: '{used} tok/h',
|
|
383
386
|
permissionsTitle: 'Permissions & Quota',
|
|
384
387
|
permFolders: 'Workspace allow-list',
|
|
385
|
-
permFoldersHint: 'Empty =
|
|
386
|
-
|
|
388
|
+
permFoldersHint: 'Empty = no workspace at all; once filled, only workspaces under these folders are. Add * to allow every workspace.',
|
|
389
|
+
permFoldersNone: 'Not granted (no workspace allowed)',
|
|
390
|
+
permFoldersAll: 'Allow all (*)',
|
|
391
|
+
permFoldersAllShort: 'All workspaces',
|
|
392
|
+
permFoldersNoneShort: 'No workspace',
|
|
387
393
|
permFolderPlaceholder: 'e.g. /srv/work or D:\\work',
|
|
388
394
|
add: 'Add',
|
|
389
395
|
remove: 'Remove',
|
|
@@ -518,6 +524,10 @@ window.__ModuleLoader__.load({
|
|
|
518
524
|
.um-wrap { font-size: 13px; color: var(--dsw-alias-label-primary); display: flex; flex-direction: column; gap: 14px; }
|
|
519
525
|
.um-avatar { border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: rgba(255,255,255,.95); font-weight: 600; flex: none; user-select: none; letter-spacing: 0; }
|
|
520
526
|
.um-brand-name { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; max-width: 150px; }
|
|
527
|
+
.um-header-user { display: inline-flex; align-items: center; }
|
|
528
|
+
.um-header-user-btn { display: inline-flex; align-items: center; gap: 7px; padding: 3px 10px 3px 3px; border: 1px solid transparent; border-radius: 999px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; line-height: 1; cursor: pointer; }
|
|
529
|
+
.um-header-user-btn:hover { background: var(--dsw-alias-interactive-bg-hover); border-color: var(--dsw-alias-border-l2); }
|
|
530
|
+
.um-header-user-name { max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
521
531
|
.um-logout { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; padding: 6px 10px; border-radius: 7px; cursor: pointer; flex: none; }
|
|
522
532
|
.um-logout:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-error-primary); }
|
|
523
533
|
.um-user-menu { min-width: 216px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.18); padding: 6px; }
|
|
@@ -650,45 +660,36 @@ window.__ModuleLoader__.load({
|
|
|
650
660
|
}, label.charAt(0).toUpperCase())
|
|
651
661
|
}
|
|
652
662
|
|
|
653
|
-
/**
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
|
|
663
|
+
/** Account menu for the right end of the conversation header.
|
|
664
|
+
*
|
|
665
|
+
* It lives in the session header's utilities group rather than the sidebar's
|
|
666
|
+
* brand seat: the brand belongs to the host (its fish logo returns there
|
|
667
|
+
* untouched), while the signed-in account belongs to the surface that acts on
|
|
668
|
+
* its behalf. Clicking opens the same menu used everywhere else — change
|
|
669
|
+
* password, sign out — with sign-out last, in red.
|
|
670
|
+
*
|
|
671
|
+
* Session-scoped by construction: the header only exists once a conversation
|
|
672
|
+
* is open, so on the welcome screen there is no account chip to click. */
|
|
673
|
+
function HeaderUserMenu() {
|
|
658
674
|
useEffect(ensureStyles, [])
|
|
659
675
|
const [me, setMe] = useState(sessionCache.me)
|
|
660
676
|
useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
|
|
661
677
|
const { toggle, menu, dialog } = useUserMenu(me)
|
|
662
|
-
if (!me)
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
678
|
+
if (!me) return null
|
|
679
|
+
return h('div', { className: 'um-header-user' },
|
|
680
|
+
h('button', {
|
|
681
|
+
type: 'button',
|
|
682
|
+
className: 'um-header-user-btn',
|
|
683
|
+
title: me.username,
|
|
684
|
+
'aria-haspopup': 'menu',
|
|
685
|
+
onClick: toggle,
|
|
686
|
+
},
|
|
687
|
+
h(UserAvatar, { username: me.username, size: 22 }),
|
|
688
|
+
h('span', { className: 'um-header-user-name' }, me.username)),
|
|
673
689
|
menu,
|
|
674
690
|
dialog)
|
|
675
691
|
}
|
|
676
692
|
|
|
677
|
-
/** Sidebar brand name — host renders it beside the expanded mark only. */
|
|
678
|
-
function BrandName() {
|
|
679
|
-
useEffect(ensureStyles, [])
|
|
680
|
-
const [me, setMe] = useState(sessionCache.me)
|
|
681
|
-
useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
|
|
682
|
-
const { toggle, menu, dialog } = useUserMenu(me)
|
|
683
|
-
return h('span', {
|
|
684
|
-
className: 'um-brand-name',
|
|
685
|
-
style: { cursor: 'pointer' },
|
|
686
|
-
title: me ? me.username : '',
|
|
687
|
-
'aria-haspopup': 'menu',
|
|
688
|
-
onClick: toggle,
|
|
689
|
-
}, me ? me.username : '', menu, dialog)
|
|
690
|
-
}
|
|
691
|
-
|
|
692
693
|
// ── logout ────────────────────────────────────────────────────────────────
|
|
693
694
|
|
|
694
695
|
/** Locale accessor for slot components living outside apply()'s closure —
|
|
@@ -879,14 +880,22 @@ window.__ModuleLoader__.load({
|
|
|
879
880
|
return byTime || byTokens
|
|
880
881
|
}
|
|
881
882
|
|
|
883
|
+
/** Whether one allow-list entry grants every workspace ('*', '/', '.' or ''). */
|
|
884
|
+
function folderAllowsEverything(entry) {
|
|
885
|
+
return entry === '*' || entry === '' || entry === '/' || entry === '.'
|
|
886
|
+
}
|
|
887
|
+
|
|
882
888
|
/** One-line limits summary for the users table. */
|
|
883
889
|
function permissionSummary(user, t) {
|
|
884
890
|
const p = user.permissions || {}
|
|
885
891
|
const bits = []
|
|
886
892
|
if (p.banned) bits.push(t('permBannedShort'))
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
893
|
+
// The workspace field always says something: an untouched account is denied,
|
|
894
|
+
// which the table must show rather than leave blank.
|
|
895
|
+
const folders = Array.isArray(p.allowedFolders) ? p.allowedFolders : []
|
|
896
|
+
if (folders.length === 0) bits.push(t('permFoldersNoneShort'))
|
|
897
|
+
else if (folders.some(folderAllowsEverything)) bits.push(t('permFoldersAllShort'))
|
|
898
|
+
else bits.push(interpolate(t('permFoldersShort'), { n: folders.length }))
|
|
890
899
|
if (p.hourlyTokenLimit != null) bits.push(interpolate(t('permTokensShort'), { n: p.hourlyTokenLimit }))
|
|
891
900
|
if (p.dailyMinuteLimit != null) bits.push(interpolate(t('permMinutesShort'), { n: p.dailyMinuteLimit }))
|
|
892
901
|
if (p.sandboxMode) bits.push(p.sandboxMode)
|
|
@@ -906,9 +915,10 @@ window.__ModuleLoader__.load({
|
|
|
906
915
|
* confirm: folding those into a save button would hide them from the operator
|
|
907
916
|
* reviewing the form before pressing it.
|
|
908
917
|
*
|
|
909
|
-
* The store's reading is surfaced verbatim: an empty folder list means
|
|
910
|
-
* workspace
|
|
911
|
-
*
|
|
918
|
+
* The store's reading is surfaced verbatim: an empty folder list means NO
|
|
919
|
+
* workspace (the whitelist denies by default, and '*' is the wide grant), an
|
|
920
|
+
* empty limit means no cap — clearing a field is a visible, deliberate act
|
|
921
|
+
* rather than a silent default. */
|
|
912
922
|
function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
|
|
913
923
|
const p = user.permissions || {}
|
|
914
924
|
const isSelf = user.id === me.id
|
|
@@ -1063,7 +1073,7 @@ window.__ModuleLoader__.load({
|
|
|
1063
1073
|
h('label', null, t('permFolders')),
|
|
1064
1074
|
h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
|
|
1065
1075
|
folders.length === 0
|
|
1066
|
-
? h('div', { className: 'um-empty' }, t('
|
|
1076
|
+
? h('div', { className: 'um-empty' }, t('permFoldersNone'))
|
|
1067
1077
|
: folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
|
|
1068
1078
|
h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
|
|
1069
1079
|
h('button', {
|
|
@@ -1076,7 +1086,12 @@ window.__ModuleLoader__.load({
|
|
|
1076
1086
|
value: draft, onChange: (e) => setDraft(e.target.value),
|
|
1077
1087
|
onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
|
|
1078
1088
|
}),
|
|
1079
|
-
h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))
|
|
1089
|
+
h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add')),
|
|
1090
|
+
// Blank now means "no workspace", so the wide grant needs its own door.
|
|
1091
|
+
h('button', {
|
|
1092
|
+
className: 'um-btn', style: { flex: 'none' },
|
|
1093
|
+
onClick: () => { if (!folders.includes('*')) setFolders([...folders, '*']) },
|
|
1094
|
+
}, t('permFoldersAll'))),
|
|
1080
1095
|
|
|
1081
1096
|
h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
|
|
1082
1097
|
h('input', {
|
|
@@ -1810,24 +1825,24 @@ window.__ModuleLoader__.load({
|
|
|
1810
1825
|
name: CLIENT_NAME,
|
|
1811
1826
|
inject: ['slots', 'locale'],
|
|
1812
1827
|
__boot,
|
|
1813
|
-
__internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar,
|
|
1828
|
+
__internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar, HeaderUserMenu, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
|
|
1814
1829
|
apply(ctx) {
|
|
1815
1830
|
ctx.locale.register(NS, 'zh', ZH)
|
|
1816
1831
|
ctx.locale.register(NS, 'en', EN)
|
|
1817
1832
|
const t = ctx.locale.bind(NS)
|
|
1818
1833
|
localeT = t
|
|
1819
1834
|
ctx.effect(() => {
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
//
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
}))
|
|
1830
|
-
}, 'user-management:
|
|
1835
|
+
// The account chip sits at the right end of the conversation header (the
|
|
1836
|
+
// utilities group). The sidebar's brand seats are deliberately left to the
|
|
1837
|
+
// host: its fish logo stays where it was, and uninstalling this plugin
|
|
1838
|
+
// leaves nothing behind.
|
|
1839
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
|
1840
|
+
name: 'conversation.session.header.utilities',
|
|
1841
|
+
id: `${CLIENT_NAME}:user-menu`,
|
|
1842
|
+
order: 50,
|
|
1843
|
+
locale: NS,
|
|
1844
|
+
}, HeaderUserMenu))
|
|
1845
|
+
}, 'user-management: session header account menu')
|
|
1831
1846
|
// Token accounting: the session-scoped dock slot is the one seat whose
|
|
1832
1847
|
// standard props carry `useProjection` — the only place the browser can
|
|
1833
1848
|
// read the host's own token totals.
|
package/client/index.js
CHANGED
|
@@ -189,8 +189,11 @@ const ZH = {
|
|
|
189
189
|
usageTokensFree: '{used} tok/h',
|
|
190
190
|
permissionsTitle: '权限与配额',
|
|
191
191
|
permFolders: '工作区白名单',
|
|
192
|
-
permFoldersHint: '留空 =
|
|
193
|
-
|
|
192
|
+
permFoldersHint: '留空 = 不允许任何工作区;填写后只允许这些目录下的工作区;条目 * 表示允许全部。',
|
|
193
|
+
permFoldersNone: '未授权(不允许任何工作区)',
|
|
194
|
+
permFoldersAll: '允许全部(*)',
|
|
195
|
+
permFoldersAllShort: '允许全部工作区',
|
|
196
|
+
permFoldersNoneShort: '禁止所有工作区',
|
|
194
197
|
permFolderPlaceholder: '例如 /srv/work 或 D:\\work',
|
|
195
198
|
add: '添加',
|
|
196
199
|
remove: '移除',
|
|
@@ -372,8 +375,11 @@ const EN = {
|
|
|
372
375
|
usageTokensFree: '{used} tok/h',
|
|
373
376
|
permissionsTitle: 'Permissions & Quota',
|
|
374
377
|
permFolders: 'Workspace allow-list',
|
|
375
|
-
permFoldersHint: 'Empty =
|
|
376
|
-
|
|
378
|
+
permFoldersHint: 'Empty = no workspace at all; once filled, only workspaces under these folders are. Add * to allow every workspace.',
|
|
379
|
+
permFoldersNone: 'Not granted (no workspace allowed)',
|
|
380
|
+
permFoldersAll: 'Allow all (*)',
|
|
381
|
+
permFoldersAllShort: 'All workspaces',
|
|
382
|
+
permFoldersNoneShort: 'No workspace',
|
|
377
383
|
permFolderPlaceholder: 'e.g. /srv/work or D:\\work',
|
|
378
384
|
add: 'Add',
|
|
379
385
|
remove: 'Remove',
|
|
@@ -508,6 +514,10 @@ const STYLE = `
|
|
|
508
514
|
.um-wrap { font-size: 13px; color: var(--dsw-alias-label-primary); display: flex; flex-direction: column; gap: 14px; }
|
|
509
515
|
.um-avatar { border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: rgba(255,255,255,.95); font-weight: 600; flex: none; user-select: none; letter-spacing: 0; }
|
|
510
516
|
.um-brand-name { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; max-width: 150px; }
|
|
517
|
+
.um-header-user { display: inline-flex; align-items: center; }
|
|
518
|
+
.um-header-user-btn { display: inline-flex; align-items: center; gap: 7px; padding: 3px 10px 3px 3px; border: 1px solid transparent; border-radius: 999px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; line-height: 1; cursor: pointer; }
|
|
519
|
+
.um-header-user-btn:hover { background: var(--dsw-alias-interactive-bg-hover); border-color: var(--dsw-alias-border-l2); }
|
|
520
|
+
.um-header-user-name { max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
511
521
|
.um-logout { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; padding: 6px 10px; border-radius: 7px; cursor: pointer; flex: none; }
|
|
512
522
|
.um-logout:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-error-primary); }
|
|
513
523
|
.um-user-menu { min-width: 216px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.18); padding: 6px; }
|
|
@@ -640,45 +650,36 @@ function UserAvatar({ username, size = 22 }) {
|
|
|
640
650
|
}, label.charAt(0).toUpperCase())
|
|
641
651
|
}
|
|
642
652
|
|
|
643
|
-
/**
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
|
|
653
|
+
/** Account menu for the right end of the conversation header.
|
|
654
|
+
*
|
|
655
|
+
* It lives in the session header's utilities group rather than the sidebar's
|
|
656
|
+
* brand seat: the brand belongs to the host (its fish logo returns there
|
|
657
|
+
* untouched), while the signed-in account belongs to the surface that acts on
|
|
658
|
+
* its behalf. Clicking opens the same menu used everywhere else — change
|
|
659
|
+
* password, sign out — with sign-out last, in red.
|
|
660
|
+
*
|
|
661
|
+
* Session-scoped by construction: the header only exists once a conversation
|
|
662
|
+
* is open, so on the welcome screen there is no account chip to click. */
|
|
663
|
+
function HeaderUserMenu() {
|
|
648
664
|
useEffect(ensureStyles, [])
|
|
649
665
|
const [me, setMe] = useState(sessionCache.me)
|
|
650
666
|
useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
|
|
651
667
|
const { toggle, menu, dialog } = useUserMenu(me)
|
|
652
|
-
if (!me)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
668
|
+
if (!me) return null
|
|
669
|
+
return h('div', { className: 'um-header-user' },
|
|
670
|
+
h('button', {
|
|
671
|
+
type: 'button',
|
|
672
|
+
className: 'um-header-user-btn',
|
|
673
|
+
title: me.username,
|
|
674
|
+
'aria-haspopup': 'menu',
|
|
675
|
+
onClick: toggle,
|
|
676
|
+
},
|
|
677
|
+
h(UserAvatar, { username: me.username, size: 22 }),
|
|
678
|
+
h('span', { className: 'um-header-user-name' }, me.username)),
|
|
663
679
|
menu,
|
|
664
680
|
dialog)
|
|
665
681
|
}
|
|
666
682
|
|
|
667
|
-
/** Sidebar brand name — host renders it beside the expanded mark only. */
|
|
668
|
-
function BrandName() {
|
|
669
|
-
useEffect(ensureStyles, [])
|
|
670
|
-
const [me, setMe] = useState(sessionCache.me)
|
|
671
|
-
useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
|
|
672
|
-
const { toggle, menu, dialog } = useUserMenu(me)
|
|
673
|
-
return h('span', {
|
|
674
|
-
className: 'um-brand-name',
|
|
675
|
-
style: { cursor: 'pointer' },
|
|
676
|
-
title: me ? me.username : '',
|
|
677
|
-
'aria-haspopup': 'menu',
|
|
678
|
-
onClick: toggle,
|
|
679
|
-
}, me ? me.username : '', menu, dialog)
|
|
680
|
-
}
|
|
681
|
-
|
|
682
683
|
// ── logout ────────────────────────────────────────────────────────────────
|
|
683
684
|
|
|
684
685
|
/** Locale accessor for slot components living outside apply()'s closure —
|
|
@@ -869,14 +870,22 @@ function overQuota(user) {
|
|
|
869
870
|
return byTime || byTokens
|
|
870
871
|
}
|
|
871
872
|
|
|
873
|
+
/** Whether one allow-list entry grants every workspace ('*', '/', '.' or ''). */
|
|
874
|
+
function folderAllowsEverything(entry) {
|
|
875
|
+
return entry === '*' || entry === '' || entry === '/' || entry === '.'
|
|
876
|
+
}
|
|
877
|
+
|
|
872
878
|
/** One-line limits summary for the users table. */
|
|
873
879
|
function permissionSummary(user, t) {
|
|
874
880
|
const p = user.permissions || {}
|
|
875
881
|
const bits = []
|
|
876
882
|
if (p.banned) bits.push(t('permBannedShort'))
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
883
|
+
// The workspace field always says something: an untouched account is denied,
|
|
884
|
+
// which the table must show rather than leave blank.
|
|
885
|
+
const folders = Array.isArray(p.allowedFolders) ? p.allowedFolders : []
|
|
886
|
+
if (folders.length === 0) bits.push(t('permFoldersNoneShort'))
|
|
887
|
+
else if (folders.some(folderAllowsEverything)) bits.push(t('permFoldersAllShort'))
|
|
888
|
+
else bits.push(interpolate(t('permFoldersShort'), { n: folders.length }))
|
|
880
889
|
if (p.hourlyTokenLimit != null) bits.push(interpolate(t('permTokensShort'), { n: p.hourlyTokenLimit }))
|
|
881
890
|
if (p.dailyMinuteLimit != null) bits.push(interpolate(t('permMinutesShort'), { n: p.dailyMinuteLimit }))
|
|
882
891
|
if (p.sandboxMode) bits.push(p.sandboxMode)
|
|
@@ -896,9 +905,10 @@ function permissionSummary(user, t) {
|
|
|
896
905
|
* confirm: folding those into a save button would hide them from the operator
|
|
897
906
|
* reviewing the form before pressing it.
|
|
898
907
|
*
|
|
899
|
-
* The store's reading is surfaced verbatim: an empty folder list means
|
|
900
|
-
* workspace
|
|
901
|
-
*
|
|
908
|
+
* The store's reading is surfaced verbatim: an empty folder list means NO
|
|
909
|
+
* workspace (the whitelist denies by default, and '*' is the wide grant), an
|
|
910
|
+
* empty limit means no cap — clearing a field is a visible, deliberate act
|
|
911
|
+
* rather than a silent default. */
|
|
902
912
|
function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
|
|
903
913
|
const p = user.permissions || {}
|
|
904
914
|
const isSelf = user.id === me.id
|
|
@@ -1053,7 +1063,7 @@ function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
|
|
|
1053
1063
|
h('label', null, t('permFolders')),
|
|
1054
1064
|
h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
|
|
1055
1065
|
folders.length === 0
|
|
1056
|
-
? h('div', { className: 'um-empty' }, t('
|
|
1066
|
+
? h('div', { className: 'um-empty' }, t('permFoldersNone'))
|
|
1057
1067
|
: folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
|
|
1058
1068
|
h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
|
|
1059
1069
|
h('button', {
|
|
@@ -1066,7 +1076,12 @@ function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
|
|
|
1066
1076
|
value: draft, onChange: (e) => setDraft(e.target.value),
|
|
1067
1077
|
onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
|
|
1068
1078
|
}),
|
|
1069
|
-
h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))
|
|
1079
|
+
h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add')),
|
|
1080
|
+
// Blank now means "no workspace", so the wide grant needs its own door.
|
|
1081
|
+
h('button', {
|
|
1082
|
+
className: 'um-btn', style: { flex: 'none' },
|
|
1083
|
+
onClick: () => { if (!folders.includes('*')) setFolders([...folders, '*']) },
|
|
1084
|
+
}, t('permFoldersAll'))),
|
|
1070
1085
|
|
|
1071
1086
|
h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
|
|
1072
1087
|
h('input', {
|
|
@@ -1800,24 +1815,24 @@ module.exports = {
|
|
|
1800
1815
|
name: CLIENT_NAME,
|
|
1801
1816
|
inject: ['slots', 'locale'],
|
|
1802
1817
|
__boot,
|
|
1803
|
-
__internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar,
|
|
1818
|
+
__internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar, HeaderUserMenu, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
|
|
1804
1819
|
apply(ctx) {
|
|
1805
1820
|
ctx.locale.register(NS, 'zh', ZH)
|
|
1806
1821
|
ctx.locale.register(NS, 'en', EN)
|
|
1807
1822
|
const t = ctx.locale.bind(NS)
|
|
1808
1823
|
localeT = t
|
|
1809
1824
|
ctx.effect(() => {
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
1813
|
-
//
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
}))
|
|
1820
|
-
}, 'user-management:
|
|
1825
|
+
// The account chip sits at the right end of the conversation header (the
|
|
1826
|
+
// utilities group). The sidebar's brand seats are deliberately left to the
|
|
1827
|
+
// host: its fish logo stays where it was, and uninstalling this plugin
|
|
1828
|
+
// leaves nothing behind.
|
|
1829
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
|
1830
|
+
name: 'conversation.session.header.utilities',
|
|
1831
|
+
id: `${CLIENT_NAME}:user-menu`,
|
|
1832
|
+
order: 50,
|
|
1833
|
+
locale: NS,
|
|
1834
|
+
}, HeaderUserMenu))
|
|
1835
|
+
}, 'user-management: session header account menu')
|
|
1821
1836
|
// Token accounting: the session-scoped dock slot is the one seat whose
|
|
1822
1837
|
// standard props carry `useProjection` — the only place the browser can
|
|
1823
1838
|
// read the host's own token totals.
|
package/docs/FAQ.md
CHANGED
|
@@ -57,7 +57,7 @@ rm ~/.dsh/user-management/users.json ~/.dsh/user-management/sessions.json
|
|
|
57
57
|
|
|
58
58
|
## 为什么直连 dsh web 端口(19080/3080)没有登录墙
|
|
59
59
|
|
|
60
|
-
v0.4 起门禁由本插件**自带的 HTTPS 网关**(默认 `https://<IP>:
|
|
60
|
+
v0.4 起门禁由本插件**自带的 HTTPS 网关**(默认 `https://<IP>:8088`)承担,不是宿主的 19080。直连 19080 等于绕过网关——**请保证 dsh web 只监听 loopback**(127.0.0.1),对外只暴露 8088。本机 `dsh web` 若被改绑到 0.0.0.0:19080,等于没有认证裸奔。
|
|
61
61
|
|
|
62
62
|
## 登录后很快又被要求登录
|
|
63
63
|
|
|
@@ -80,13 +80,13 @@ v0.4 起门禁由本插件**自带的 HTTPS 网关**(默认 `https://<IP>:1984
|
|
|
80
80
|
## 网关没起来 / 端口被占用
|
|
81
81
|
|
|
82
82
|
```bash
|
|
83
|
-
# 谁占着
|
|
84
|
-
lsof -iTCP:
|
|
83
|
+
# 谁占着 8088:
|
|
84
|
+
lsof -iTCP:8088
|
|
85
85
|
# 启动日志(用户管理所有 [user-management] 前缀的输出都在这里):
|
|
86
86
|
grep user-management ~/.dsh/logs/web.out.log ~/.dsh/logs/web.err.log
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
- `EADDRINUSE`:
|
|
89
|
+
- `EADDRINUSE`:8088 被别的进程(或另一个 dsh 实例)占了——杀掉占用者,或在 `~/.dsh/settings.yaml` 的 `user-management:` 段改 `port`
|
|
90
90
|
- 配置了 `enabled: false` 网关不会启动
|
|
91
91
|
- 改了 hosts/证书配置不生效:删除 `~/.dsh/user-management/certs/` 下的旧证书文件再重启(旧证书按指纹稳定复用,不会自动重签)
|
|
92
92
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kczx-user-management",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。",
|
|
6
6
|
"license": "MIT",
|