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.
@@ -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: 19843
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>:19843/login` renders, a wrong password is refused, a correct
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.
@@ -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: 19843
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:19843
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:19843`),自签证书(SAN 覆盖本机全部 IP + sslip.io / nip.io 别名),
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
- | 工作区白名单 | 留空 = 允许全部;填写后只允许这些目录(Windows 路径折叠大小写,末尾斜杠自动忽略) |
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: 19843
116
+ port: 8088
98
117
  plaintext: false # true 时只允许回环(明文服务观察层用,见 MERGED-DEPLOYMENT.md)
99
118
  sites: [] # 空 = 自动枚举本机 IP;配域名 + 证书则按 SNI 选择
100
119
  title: 'DSH 控制台'
101
- autoActivate: false # false = 自助注册需管理员启用
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
- permFoldersAll: '未限制(允许全部工作区)',
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 = every workspace is allowed; once filled, only workspaces under these folders are.',
386
- permFoldersAll: 'Unrestricted (all workspaces allowed)',
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
- /** Sidebar brand mark host renders it in the expanded brand row AND the
654
- * collapsed rail (owner supplies the square `size`), so fold states adapt
655
- * without us tracking the shell. Clicking it (or the name) opens the
656
- * user menu; sign-out sits at the bottom in red. */
657
- function BrandMark({ size, className }) {
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
- return h('div', { className, style: { width: size, height: size, borderRadius: '50%', background: 'var(--dsw-alias-bg-layer-3)' } })
664
- }
665
- return h('div', {
666
- className,
667
- style: { cursor: 'pointer', display: 'inline-flex', borderRadius: '50%' },
668
- title: me.username,
669
- 'aria-haspopup': 'menu',
670
- onClick: toggle,
671
- },
672
- h(UserAvatar, { username: me.username, size: size || 24 }),
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
- if (Array.isArray(p.allowedFolders) && p.allowedFolders.length > 0) {
888
- bits.push(interpolate(t('permFoldersShort'), { n: p.allowedFolders.length }))
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 every
910
- * workspace, an empty limit means no cap clearing a field is a visible,
911
- * deliberate act rather than a silent default. */
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('permFoldersAll'))
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, BrandMark, BrandName, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
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
- // Official-brand pattern: nested injects claim both brand seats, one
1821
- // generator registers them. The mark shows in expanded row + collapsed
1822
- // rail (fold-adaptive), the name only beside the expanded mark.
1823
- // priority: -1 shadows the host's default brand registration at 0
1824
- // ("lowest renders"); uninstalling this plugin restores the official
1825
- // brand with no residue.
1826
- ctx.slots.inject('sidebar.brand.mark', () => ctx.slots.inject('sidebar.brand.name', function* () {
1827
- yield ctx.slots.register({ name: 'sidebar.brand.mark', priority: -1 }, BrandMark)
1828
- yield ctx.slots.register({ name: 'sidebar.brand.name', priority: -1 }, BrandName)
1829
- }))
1830
- }, 'user-management: sidebar brand')
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
- permFoldersAll: '未限制(允许全部工作区)',
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 = every workspace is allowed; once filled, only workspaces under these folders are.',
376
- permFoldersAll: 'Unrestricted (all workspaces allowed)',
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
- /** Sidebar brand mark host renders it in the expanded brand row AND the
644
- * collapsed rail (owner supplies the square `size`), so fold states adapt
645
- * without us tracking the shell. Clicking it (or the name) opens the
646
- * user menu; sign-out sits at the bottom in red. */
647
- function BrandMark({ size, className }) {
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
- return h('div', { className, style: { width: size, height: size, borderRadius: '50%', background: 'var(--dsw-alias-bg-layer-3)' } })
654
- }
655
- return h('div', {
656
- className,
657
- style: { cursor: 'pointer', display: 'inline-flex', borderRadius: '50%' },
658
- title: me.username,
659
- 'aria-haspopup': 'menu',
660
- onClick: toggle,
661
- },
662
- h(UserAvatar, { username: me.username, size: size || 24 }),
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
- if (Array.isArray(p.allowedFolders) && p.allowedFolders.length > 0) {
878
- bits.push(interpolate(t('permFoldersShort'), { n: p.allowedFolders.length }))
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 every
900
- * workspace, an empty limit means no cap clearing a field is a visible,
901
- * deliberate act rather than a silent default. */
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('permFoldersAll'))
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, BrandMark, BrandName, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
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
- // Official-brand pattern: nested injects claim both brand seats, one
1811
- // generator registers them. The mark shows in expanded row + collapsed
1812
- // rail (fold-adaptive), the name only beside the expanded mark.
1813
- // priority: -1 shadows the host's default brand registration at 0
1814
- // ("lowest renders"); uninstalling this plugin restores the official
1815
- // brand with no residue.
1816
- ctx.slots.inject('sidebar.brand.mark', () => ctx.slots.inject('sidebar.brand.name', function* () {
1817
- yield ctx.slots.register({ name: 'sidebar.brand.mark', priority: -1 }, BrandMark)
1818
- yield ctx.slots.register({ name: 'sidebar.brand.name', priority: -1 }, BrandName)
1819
- }))
1820
- }, 'user-management: sidebar brand')
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>:19843`)承担,不是宿主的 19080。直连 19080 等于绕过网关——**请保证 dsh web 只监听 loopback**(127.0.0.1),对外只暴露 19843。本机 `dsh web` 若被改绑到 0.0.0.0:19080,等于没有认证裸奔。
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
- # 谁占着 19843
84
- lsof -iTCP:19843
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`:19843 被别的进程(或另一个 dsh 实例)占了——杀掉占用者,或在 `~/.dsh/settings.yaml` 的 `user-management:` 段改 `port`
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.2",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "description": "dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。",
6
6
  "license": "MIT",