kczx-user-management 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -66,7 +66,8 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
66
66
 
67
67
  | 能力 | 说明 |
68
68
  |---|---|
69
- | 用户增删改查 | 新增 / 删除 / 角色调整 / 禁用启用 / 重置密码(一次性临时密码)/ 重置两步验证 |
69
+ | 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
70
+ | 所属部门 | 账号资料字段(trim 后 ≤ 64 字,控制字符剔除):新增用户时可填,编辑页可随时改或清空;变更写进操作日志 |
70
71
  | 工作区白名单 | 留空 = 允许全部;填写后只允许这些目录(Windows 路径折叠大小写,末尾斜杠自动忽略) |
71
72
  | 工作区归属跟踪 | 谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理 |
72
73
  | 每小时 token 配额 | 由浏览器上报 dsh 自身的用量投影增量,服务端按小时窗口累计并拦截 |
@@ -98,6 +99,7 @@ user-management:
98
99
  sites: [] # 空 = 自动枚举本机 IP;配域名 + 证书则按 SNI 选择
99
100
  title: 'DSH 控制台'
100
101
  autoActivate: false # false = 自助注册需管理员启用
102
+ historyMaxBytes: 67108864 # 历史改写缓冲上限(字节,默认 64 MiB,最小 1 MiB)
101
103
  ```
102
104
 
103
105
  ## 数据文件
@@ -106,7 +108,7 @@ user-management:
106
108
 
107
109
  | 文件 | 内容 |
108
110
  |---|---|
109
- | `users.json` | 账号(scrypt 哈希;迁移进来的账号保留 bcrypt,首次登录成功后自动升级为 scrypt |
111
+ | `users.json` | 账号(scrypt 哈希;迁移进来的账号保留 bcrypt,首次登录成功后自动升级为 scrypt;含 `department` 所属部门) |
110
112
  | `sessions.json` | 会话令牌(重启不掉线,7 天滑动过期) |
111
113
  | `activity.jsonl` / `audit.jsonl` | 登录与访问台账 / 操作日志(滚动保留最近若干条) |
112
114
  | `bans.json` | IP 封禁 |
@@ -130,7 +132,7 @@ node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --ove
130
132
  ## 测试
131
133
 
132
134
  ```bash
133
- npm test # 83 项:策略纯函数、store、API 层、以及真实网关的端到端用例
135
+ npm test # 93 项:策略纯函数、store、API 层、以及真实网关的端到端用例
134
136
  npm run check # 全量语法检查
135
137
  ```
136
138
 
@@ -151,7 +153,10 @@ dsh plugin --profile web remove kczx-user-management
151
153
  - **门只管走门的人**:若另有插件把 dsh 宿主绑到 `0.0.0.0`(例如 dsh-lan-access),直连宿主端口即可绕过登录,
152
154
  且**绕过流量不会出现在任何一本台账里**。要真正的强制约束,宿主端口必须保持回环。
153
155
  - **token 配额依赖客户端上报**:浏览器侧被禁用或页面未加载时不计费(时长配额不受影响,它在服务端计时)。
154
- - 上游 `@weibaohui/user-management`(0.9.5)与本包 `kczx-user-management`(1.0.0)是两个不同的包,互不覆盖,`pnpm update` 不会把两者串在一起。
156
+ - **会话历史要缓冲后才能改写**:历史响应是**一个 JSON 文档**(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符
157
+ 清洗,因此有 `historyMaxBytes` 上限(默认 64 MiB)。超过上限的会话会 **fail-closed 返回 502**,UI 显示「历史加载失败」——
158
+ 调大该值即可;1.0.0 之前用代理默认的 8 MiB,长会话必然踩中。
159
+ - 上游 `@weibaohui/user-management`(0.9.5)与本包 `kczx-user-management` 是两个不同的包,互不覆盖,`pnpm update` 不会把两者串在一起。
155
160
 
156
161
  ## License
157
162
 
package/client/bundle.js CHANGED
@@ -105,8 +105,21 @@ window.__ModuleLoader__.load({
105
105
  actionLogout: '退出登录',
106
106
  createUser: '新增用户',
107
107
  createUserTitle: '新增用户',
108
+ actionEdit: '编辑',
109
+ editUserTitle: '编辑用户',
110
+ sectionProfile: '基本资料',
111
+ sectionAccount: '账号操作',
112
+ accountActionsHint: '以上操作立即生效且不可撤销;角色、部门、权限与配额的改动请用右上角「保存」提交。',
113
+ editSelfHint: '不能修改自己的角色,也不能禁用或删除自己的账号。',
114
+ close: '关闭',
108
115
  colPassword: '密码(至少 6 位)',
109
116
  colRolePick: '角色',
117
+ colDepartment: '所属部门',
118
+ departmentPlaceholder: '所属部门(可选,最多 64 字)',
119
+ departmentUnset: '未设置',
120
+ editDepartmentHint: '留空表示清除该用户的所属部门。',
121
+ departmentUpdated: '所属部门已更新',
122
+ typeProfileChange: '资料变更',
110
123
  submitCreate: '创建',
111
124
  userCreated: '用户已创建',
112
125
  actionDisable: '禁用',
@@ -184,7 +197,6 @@ window.__ModuleLoader__.load({
184
197
  usageMinutesFree: '{used} 分钟',
185
198
  usageTokens: '{used}/{limit} tok/h',
186
199
  usageTokensFree: '{used} tok/h',
187
- actionPermissions: '权限/配额',
188
200
  permissionsTitle: '权限与配额',
189
201
  permFolders: '工作区白名单',
190
202
  permFoldersHint: '留空 = 允许访问所有工作区;填写后只允许这些目录下的工作区。',
@@ -276,8 +288,21 @@ window.__ModuleLoader__.load({
276
288
  actionLogout: 'Sign out',
277
289
  createUser: 'New User',
278
290
  createUserTitle: 'New User',
291
+ actionEdit: 'Edit',
292
+ editUserTitle: 'Edit user',
293
+ sectionProfile: 'Profile',
294
+ sectionAccount: 'Account actions',
295
+ accountActionsHint: 'These take effect immediately and cannot be undone. Role, department, permissions and quota are written by Save (top right).',
296
+ editSelfHint: 'You cannot change your own role, disable, or delete your own account.',
297
+ close: 'Close',
279
298
  colPassword: 'Password (min 6 chars)',
280
299
  colRolePick: 'Role',
300
+ colDepartment: 'Department',
301
+ departmentPlaceholder: 'Department (optional, max 64 chars)',
302
+ departmentUnset: 'Not set',
303
+ editDepartmentHint: 'Leave empty to clear this account’s department.',
304
+ departmentUpdated: 'Department updated',
305
+ typeProfileChange: 'Profile change',
281
306
  submitCreate: 'Create',
282
307
  userCreated: 'User created',
283
308
  actionDisable: 'Disable',
@@ -355,7 +380,6 @@ window.__ModuleLoader__.load({
355
380
  usageMinutesFree: '{used} min',
356
381
  usageTokens: '{used}/{limit} tok/h',
357
382
  usageTokensFree: '{used} tok/h',
358
- actionPermissions: 'Limits',
359
383
  permissionsTitle: 'Permissions & Quota',
360
384
  permFolders: 'Workspace allow-list',
361
385
  permFoldersHint: 'Empty = every workspace is allowed; once filled, only workspaces under these folders are.',
@@ -399,6 +423,7 @@ window.__ModuleLoader__.load({
399
423
  delete_user: 'typeDeleteUser',
400
424
  access: 'typeAccess',
401
425
  user_created: 'typeUserCreated',
426
+ profile_change: 'typeProfileChange',
402
427
  user_disabled: 'typeUserDisabled',
403
428
  user_enabled: 'typeUserEnabled',
404
429
  ban_ip: 'typeBanIp',
@@ -870,16 +895,25 @@ window.__ModuleLoader__.load({
870
895
  return bits.length === 0 ? t('permUnrestricted') : bits.join(' · ')
871
896
  }
872
897
 
873
- /** Per-user permissions & quota editor (admin only).
898
+ /** Full-page account editor (admin only): profile, account operations and the
899
+ * permission/quota model that used to live in dsh-passwords, on one surface.
874
900
  *
875
- * This is the account model that used to live in dsh-passwords: workspace
876
- * scope, an hourly token cap, daily minutes, a sandbox floor, upload/git
877
- * switches and the policy ban. The store's reading is surfaced verbatim in the
878
- * form: an empty folder list means every workspace, an empty limit means no
879
- * cap so clearing a field is a visible, deliberate act rather than a silent
880
- * default. */
881
- function PermissionsDialog({ user, onClose, onSaved, __t: t }) {
901
+ * Editing and acting are deliberately separated. Everything that is just state
902
+ * (department, role, workspace scope, quotas, sandbox floor, upload/git
903
+ * switches, ban, note) is staged in the form and written by 保存. Everything
904
+ * that takes effect the moment it is pressed and is hard to walk back (reset
905
+ * password, reset TOTP, disable, delete) stays an explicit button with its own
906
+ * confirm: folding those into a save button would hide them from the operator
907
+ * reviewing the form before pressing it.
908
+ *
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. */
912
+ function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
882
913
  const p = user.permissions || {}
914
+ const isSelf = user.id === me.id
915
+ const [department, setDepartment] = useState(user.department || '')
916
+ const [role, setRole] = useState(user.role)
883
917
  const [folders, setFolders] = useState(Array.isArray(p.allowedFolders) ? p.allowedFolders.slice() : [])
884
918
  const [draft, setDraft] = useState('')
885
919
  const [tokenLimit, setTokenLimit] = useState(p.hourlyTokenLimit == null ? '' : String(p.hourlyTokenLimit))
@@ -892,6 +926,13 @@ window.__ModuleLoader__.load({
892
926
  const [busy, setBusy] = useState(false)
893
927
  const [err, setErr] = useState('')
894
928
 
929
+ // Same walk-back affordance as the console page this opens from: Escape closes.
930
+ useEffect(() => {
931
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
932
+ document.addEventListener('keydown', onKey)
933
+ return () => document.removeEventListener('keydown', onKey)
934
+ }, [onClose])
935
+
895
936
  const addFolder = () => {
896
937
  const value = draft.trim()
897
938
  if (value === '' || folders.includes(value)) { setDraft(''); return }
@@ -900,11 +941,27 @@ window.__ModuleLoader__.load({
900
941
  }
901
942
  const toLimit = (value) => (String(value).trim() === '' ? null : Number(value))
902
943
 
903
- const submit = () => {
944
+ /** One write at a time, with the failure kept on the page rather than flashed
945
+ * away — an edit this wide needs the error next to the fields it concerns. */
946
+ const run = (fn) => {
904
947
  if (busy) return
905
948
  setBusy(true)
906
949
  setErr('')
907
- api(`/users/${user.id}/permissions`, {
950
+ Promise.resolve().then(fn).catch((e) => setErr(String(e && e.message))).finally(() => setBusy(false))
951
+ }
952
+
953
+ const save = () => run(async () => {
954
+ // Role first: it is the one staged edit behind a confirm, so cancelling
955
+ // that confirm leaves nothing else written.
956
+ if (role !== user.role) {
957
+ const label = role === 'admin' ? t('roleAdmin') : t('roleUser')
958
+ if (!window.confirm(interpolate(t('roleConfirm'), { name: user.username, role: label }))) return
959
+ await api(`/users/${user.id}/role`, { method: 'POST', body: { role } })
960
+ }
961
+ if (department !== (user.department || '')) {
962
+ await api(`/users/${user.id}/profile`, { method: 'PATCH', body: { department } })
963
+ }
964
+ await api(`/users/${user.id}/permissions`, {
908
965
  method: 'PUT',
909
966
  body: {
910
967
  allowedFolders: folders,
@@ -917,153 +974,163 @@ window.__ModuleLoader__.load({
917
974
  note,
918
975
  },
919
976
  })
920
- .then(() => { onSaved() })
921
- .catch((e) => setErr(String(e && e.message)))
922
- .finally(() => setBusy(false))
923
- }
977
+ onDone()
978
+ })
924
979
 
925
- return h(UmDialog, {
926
- title: `${t('permissionsTitle')} · ${user.username}`,
927
- onClose,
928
- footer: [
929
- h('button', { className: 'um-btn', onClick: onClose }, '✕'),
930
- h('button', { className: 'um-btn um-btn-primary', onClick: submit, disabled: busy }, t('save')),
931
- ],
932
- },
933
- err && h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, marginBottom: 6 } }, err),
934
- // Setting a limit is a judgement about consumption, so show it: an
935
- // administrator otherwise has to guess what "100k tokens an hour" means for
936
- // this particular account.
937
- usageSummary(user, t)
938
- ? h('div', { className: 'um-muted', style: { fontSize: 11, marginBottom: 8 } }, interpolate(t('usageToday'), { summary: usageSummary(user, t) }))
939
- : null,
940
- h('div', { className: 'um-form', style: { maxWidth: 'none' } },
941
- h('label', null, t('permFolders')),
942
- h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
943
- folders.length === 0
944
- ? h('div', { className: 'um-empty' }, t('permFoldersAll'))
945
- : folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
946
- h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
947
- h('button', {
948
- className: 'um-btn um-btn-danger', style: { flex: 'none' },
949
- onClick: () => setFolders(folders.filter((_, i) => i !== index)),
950
- }, t('remove')))),
951
- h('div', { className: 'um-row' },
952
- h('input', {
953
- className: 'um-input', style: { flex: 1 }, placeholder: t('permFolderPlaceholder'),
954
- value: draft, onChange: (e) => setDraft(e.target.value),
955
- onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
956
- }),
957
- h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))),
980
+ const resetPwd = () => run(async () => {
981
+ if (!window.confirm(interpolate(t('resetPwdConfirm'), { name: user.username }))) return
982
+ const d = await api(`/users/${user.id}/reset-password`, { method: 'POST' })
983
+ onTempPassword({ name: user.username, pwd: d.tempPassword })
984
+ })
958
985
 
959
- h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
960
- h('input', {
961
- id: 'um-perm-tokens', className: 'um-input', inputMode: 'numeric',
962
- placeholder: t('permUnlimited'), value: tokenLimit, onChange: (e) => setTokenLimit(e.target.value),
963
- }),
964
- h('label', { htmlFor: 'um-perm-minutes' }, t('permMinuteLimit')),
965
- h('input', {
966
- id: 'um-perm-minutes', className: 'um-input', inputMode: 'numeric',
967
- placeholder: t('permUnlimited'), value: minuteLimit, onChange: (e) => setMinuteLimit(e.target.value),
968
- }),
986
+ const resetTotp = () => run(async () => {
987
+ if (!window.confirm(interpolate(t('totpResetConfirm'), { name: user.username }))) return
988
+ await api(`/users/${user.id}/reset-totp`, { method: 'POST' })
989
+ onDone()
990
+ })
969
991
 
970
- h('label', { htmlFor: 'um-perm-sandbox' }, t('permSandbox')),
971
- h('select', {
972
- id: 'um-perm-sandbox', className: 'um-input', value: sandbox,
973
- onChange: (e) => setSandbox(e.target.value),
974
- },
975
- h('option', { value: '' }, t('permSandboxNone')),
976
- h('option', { value: 'read-only' }, t('permSandboxReadOnly')),
977
- h('option', { value: 'workspace-write' }, t('permSandboxWorkspace')),
978
- h('option', { value: 'danger-full-access' }, t('permSandboxFull'))),
979
-
980
- h('label', { className: 'um-row', style: { gap: 6 } },
981
- h('input', { type: 'checkbox', checked: allowUpload, onChange: (e) => setAllowUpload(e.target.checked) }),
982
- t('permAllowUpload')),
983
- h('label', { className: 'um-row', style: { gap: 6 } },
984
- h('input', { type: 'checkbox', checked: allowGit, onChange: (e) => setAllowGit(e.target.checked) }),
985
- t('permAllowGit')),
986
- h('label', { className: 'um-row', style: { gap: 6 } },
987
- h('input', { type: 'checkbox', checked: banned, onChange: (e) => setBanned(e.target.checked) }),
988
- t('permBanned')),
989
-
990
- h('label', { htmlFor: 'um-perm-note' }, t('permNote')),
991
- h('input', {
992
- id: 'um-perm-note', className: 'um-input', maxLength: 200,
993
- value: note, onChange: (e) => setNote(e.target.value),
994
- })))
992
+ const toggleDisabled = () => run(async () => {
993
+ const disabling = !user.disabled
994
+ const key = disabling ? 'disableConfirm' : 'enableConfirm'
995
+ if (!window.confirm(interpolate(t(key), { name: user.username }))) return
996
+ await api(`/users/${user.id}/disabled`, { method: 'POST', body: { disabled: disabling } })
997
+ onDone()
998
+ })
999
+
1000
+ const remove = () => run(async () => {
1001
+ if (!window.confirm(interpolate(t('deleteConfirm'), { name: user.username }))) return
1002
+ await api(`/users/${user.id}`, { method: 'DELETE' })
1003
+ onDone()
1004
+ })
1005
+
1006
+ return h('div', { className: 'um-page-backdrop', role: 'dialog', 'aria-label': t('editUserTitle') },
1007
+ h('div', { className: 'um-page-head' },
1008
+ h('h3', { className: 'um-page-title' }, `${t('editUserTitle')} · ${user.username}`),
1009
+ h('div', { className: 'um-row' },
1010
+ h('button', { className: 'um-btn', onClick: onClose }, t('close')),
1011
+ h('button', { className: 'um-btn um-btn-primary', disabled: busy, onClick: save }, t('save')))),
1012
+ h('div', { className: 'um-page-body' },
1013
+ h('div', { className: 'um-page-inner' },
1014
+ err && h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, marginBottom: 8 } }, err),
1015
+
1016
+ h('div', { className: 'um-card', style: { marginBottom: 14 } },
1017
+ h('h3', { className: 'um-title' }, t('sectionProfile')),
1018
+ h('div', { className: 'um-form', style: { maxWidth: 420 } },
1019
+ h('label', { htmlFor: 'um-edit-username' }, t('colUser')),
1020
+ h('input', { id: 'um-edit-username', className: 'um-input', value: user.username, disabled: true }),
1021
+ h('label', { htmlFor: 'um-edit-department' }, t('colDepartment')),
1022
+ h('input', {
1023
+ id: 'um-edit-department', className: 'um-input', value: department, maxLength: 64,
1024
+ placeholder: t('departmentPlaceholder'),
1025
+ onChange: (e) => setDepartment(e.target.value),
1026
+ }),
1027
+ h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('editDepartmentHint')),
1028
+ h('label', { htmlFor: 'um-edit-role' }, t('colRolePick')),
1029
+ h('select', {
1030
+ id: 'um-edit-role', className: 'um-input', value: role, disabled: isSelf,
1031
+ onChange: (e) => setRole(e.target.value),
1032
+ },
1033
+ h('option', { value: 'user' }, t('roleUser')),
1034
+ h('option', { value: 'admin' }, t('roleAdmin'))),
1035
+ isSelf ? h('div', { className: 'um-muted', style: { fontSize: 11 } }, t('editSelfHint')) : null)),
1036
+
1037
+ h('div', { className: 'um-card', style: { marginBottom: 14 } },
1038
+ h('h3', { className: 'um-title' }, t('sectionAccount')),
1039
+ h('div', { className: 'um-row', style: { flexWrap: 'wrap' } },
1040
+ h('button', { className: 'um-btn', disabled: busy, onClick: resetPwd }, t('actionResetPwd')),
1041
+ user.totpEnabled
1042
+ ? h('button', { className: 'um-btn', disabled: busy, onClick: resetTotp }, t('totpResetAction'))
1043
+ : null,
1044
+ h('button', {
1045
+ className: user.disabled ? 'um-btn' : 'um-btn um-btn-danger',
1046
+ disabled: busy || isSelf, title: isSelf ? '-' : undefined, onClick: toggleDisabled,
1047
+ }, user.disabled ? t('actionEnable') : t('actionDisable')),
1048
+ h('button', {
1049
+ className: 'um-btn um-btn-danger',
1050
+ disabled: busy || isSelf, title: isSelf ? '-' : undefined, onClick: remove,
1051
+ }, t('actionDelete'))),
1052
+ h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: 8 } }, t('accountActionsHint'))),
1053
+
1054
+ h('div', { className: 'um-card' },
1055
+ h('h3', { className: 'um-title' }, t('permissionsTitle')),
1056
+ // Setting a limit is a judgement about consumption, so show it: an
1057
+ // administrator otherwise has to guess what "100k tokens an hour"
1058
+ // means for this particular account.
1059
+ usageSummary(user, t)
1060
+ ? h('div', { className: 'um-muted', style: { fontSize: 11, marginBottom: 8 } }, interpolate(t('usageToday'), { summary: usageSummary(user, t) }))
1061
+ : null,
1062
+ h('div', { className: 'um-form', style: { maxWidth: 'none' } },
1063
+ h('label', null, t('permFolders')),
1064
+ h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
1065
+ folders.length === 0
1066
+ ? h('div', { className: 'um-empty' }, t('permFoldersAll'))
1067
+ : folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
1068
+ h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
1069
+ h('button', {
1070
+ className: 'um-btn um-btn-danger', style: { flex: 'none' },
1071
+ onClick: () => setFolders(folders.filter((_, i) => i !== index)),
1072
+ }, t('remove')))),
1073
+ h('div', { className: 'um-row' },
1074
+ h('input', {
1075
+ className: 'um-input', style: { flex: 1 }, placeholder: t('permFolderPlaceholder'),
1076
+ value: draft, onChange: (e) => setDraft(e.target.value),
1077
+ onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
1078
+ }),
1079
+ h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))),
1080
+
1081
+ h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
1082
+ h('input', {
1083
+ id: 'um-perm-tokens', className: 'um-input', inputMode: 'numeric',
1084
+ placeholder: t('permUnlimited'), value: tokenLimit, onChange: (e) => setTokenLimit(e.target.value),
1085
+ }),
1086
+ h('label', { htmlFor: 'um-perm-minutes' }, t('permMinuteLimit')),
1087
+ h('input', {
1088
+ id: 'um-perm-minutes', className: 'um-input', inputMode: 'numeric',
1089
+ placeholder: t('permUnlimited'), value: minuteLimit, onChange: (e) => setMinuteLimit(e.target.value),
1090
+ }),
1091
+
1092
+ h('label', { htmlFor: 'um-perm-sandbox' }, t('permSandbox')),
1093
+ h('select', {
1094
+ id: 'um-perm-sandbox', className: 'um-input', value: sandbox,
1095
+ onChange: (e) => setSandbox(e.target.value),
1096
+ },
1097
+ h('option', { value: '' }, t('permSandboxNone')),
1098
+ h('option', { value: 'read-only' }, t('permSandboxReadOnly')),
1099
+ h('option', { value: 'workspace-write' }, t('permSandboxWorkspace')),
1100
+ h('option', { value: 'danger-full-access' }, t('permSandboxFull'))),
1101
+
1102
+ h('label', { className: 'um-row', style: { gap: 6 } },
1103
+ h('input', { type: 'checkbox', checked: allowUpload, onChange: (e) => setAllowUpload(e.target.checked) }),
1104
+ t('permAllowUpload')),
1105
+ h('label', { className: 'um-row', style: { gap: 6 } },
1106
+ h('input', { type: 'checkbox', checked: allowGit, onChange: (e) => setAllowGit(e.target.checked) }),
1107
+ t('permAllowGit')),
1108
+ h('label', { className: 'um-row', style: { gap: 6 } },
1109
+ h('input', { type: 'checkbox', checked: banned, onChange: (e) => setBanned(e.target.checked) }),
1110
+ t('permBanned')),
1111
+
1112
+ h('label', { htmlFor: 'um-perm-note' }, t('permNote')),
1113
+ h('input', {
1114
+ id: 'um-perm-note', className: 'um-input', maxLength: 200,
1115
+ value: note, onChange: (e) => setNote(e.target.value),
1116
+ }))))))
995
1117
  }
996
1118
 
997
1119
  function UsersTab({ me, __t: t, flash }) {
998
1120
  const [users, setUsers] = useState(null)
999
1121
  const [temp, setTemp] = useState(null) // {name, pwd}
1000
- const [busyId, setBusyId] = useState(null)
1001
1122
  const [copied, setCopied] = useState(false)
1002
1123
  const [creating, setCreating] = useState(false)
1003
- const [permUser, setPermUser] = useState(null)
1004
- const [form, setForm] = useState({ username: '', password: '', role: 'user' })
1124
+ const [editUser, setEditUser] = useState(null)
1125
+ const [form, setForm] = useState({ username: '', password: '', role: 'user', department: '' })
1005
1126
 
1006
1127
  const load = () => api('/users').then((d) => setUsers(d.users)).catch((e) => flash(t('failed') + e.message))
1007
1128
  useEffect(() => { load() }, [])
1008
1129
 
1009
- const act = async (user, fn) => {
1010
- setBusyId(user.id)
1011
- try { await fn() } catch (e) { flash(t('failed') + e.message) } finally { setBusyId(null) }
1012
- }
1013
-
1014
- const resetPwd = (user) => {
1015
- if (!window.confirm(interpolate(t('resetPwdConfirm'), { name: user.username }))) return
1016
- act(user, async () => {
1017
- const d = await api(`/users/${user.id}/reset-password`, { method: 'POST' })
1018
- setTemp({ name: user.username, pwd: d.tempPassword })
1019
- setCopied(false)
1020
- })
1021
- }
1022
-
1023
- const changeRole = (user) => {
1024
- const nextRole = user.role === 'admin' ? 'user' : 'admin'
1025
- const label = nextRole === 'admin' ? t('roleAdmin') : t('roleUser')
1026
- if (!window.confirm(interpolate(t('roleConfirm'), { name: user.username, role: label }))) return
1027
- act(user, async () => {
1028
- await api(`/users/${user.id}/role`, { method: 'POST', body: { role: nextRole } })
1029
- flash(t('opOk'))
1030
- await load()
1031
- })
1032
- }
1033
-
1034
- const toggleDisabled = (user) => {
1035
- const disabling = !user.disabled
1036
- const key = disabling ? 'disableConfirm' : 'enableConfirm'
1037
- if (!window.confirm(interpolate(t(key), { name: user.username }))) return
1038
- act(user, async () => {
1039
- await api(`/users/${user.id}/disabled`, { method: 'POST', body: { disabled: disabling } })
1040
- flash(t('opOk'))
1041
- await load()
1042
- })
1043
- }
1044
-
1045
- const remove = (user) => {
1046
- if (!window.confirm(interpolate(t('deleteConfirm'), { name: user.username }))) return
1047
- act(user, async () => {
1048
- await api(`/users/${user.id}`, { method: 'DELETE' })
1049
- flash(t('opOk'))
1050
- await load()
1051
- })
1052
- }
1053
-
1054
- const resetTotp = (user) => {
1055
- if (!window.confirm(interpolate(t('totpResetConfirm'), { name: user.username }))) return
1056
- act(user, async () => {
1057
- await api(`/users/${user.id}/reset-totp`, { method: 'POST' })
1058
- flash(t('totpResetDone'))
1059
- await load()
1060
- })
1061
- }
1062
-
1063
1130
  const submitCreate = (e) => {
1064
1131
  e.preventDefault()
1065
1132
  api('/users', { method: 'POST', body: form })
1066
- .then(() => { setCreating(false); setForm({ username: '', password: '', role: 'user' }); flash(t('userCreated')); return load() })
1133
+ .then(() => { setCreating(false); setForm({ username: '', password: '', role: 'user', department: '' }); flash(t('userCreated')); return load() })
1067
1134
  .catch((err) => flash(t('failed') + err.message))
1068
1135
  }
1069
1136
 
@@ -1085,6 +1152,7 @@ window.__ModuleLoader__.load({
1085
1152
  h('table', { className: 'um-table' },
1086
1153
  h('thead', null, h('tr', null,
1087
1154
  h('th', null, t('colUser')),
1155
+ h('th', null, t('colDepartment')),
1088
1156
  h('th', null, t('colRole')),
1089
1157
  h('th', null, t('colCreatedAt')),
1090
1158
  h('th', null, t('colLastLogin')),
@@ -1092,6 +1160,7 @@ window.__ModuleLoader__.load({
1092
1160
  h('th', null, ''))),
1093
1161
  h('tbody', null, users.map((user) => h('tr', { key: user.id, style: user.disabled ? { opacity: 0.55 } : undefined },
1094
1162
  h('td', null, user.username, user.id === me.id ? t('you') : null),
1163
+ h('td', { className: user.department ? undefined : 'um-muted' }, user.department || t('departmentUnset')),
1095
1164
  h('td', null, h('span', { className: 'um-row', style: { gap: 4 } },
1096
1165
  h(RoleBadge, { role: user.role, __t: t }),
1097
1166
  user.disabled ? h('span', { className: 'um-badge um-badge-err' }, t('badgeDisabled')) : null,
@@ -1109,26 +1178,7 @@ window.__ModuleLoader__.load({
1109
1178
  ? h('div', { className: 'um-muted', style: { fontSize: 11, whiteSpace: 'nowrap' } }, usageSummary(user, t))
1110
1179
  : null)),
1111
1180
  h('td', null, h('div', { className: 'um-row', style: { justifyContent: 'flex-end' } },
1112
- h('button', { className: 'um-btn', disabled: busyId === user.id, onClick: () => setPermUser(user) }, t('actionPermissions')),
1113
- h('button', { className: 'um-btn', disabled: busyId === user.id, onClick: () => resetPwd(user) }, t('actionResetPwd')),
1114
- h('button', {
1115
- className: 'um-btn', disabled: busyId === user.id || user.id === me.id,
1116
- title: user.id === me.id ? '-' : undefined, onClick: () => changeRole(user),
1117
- }, user.role === 'admin' ? t('actionSetUser') : t('actionSetAdmin')),
1118
- h('button', {
1119
- className: user.disabled ? 'um-btn' : 'um-btn um-btn-danger',
1120
- disabled: busyId === user.id || user.id === me.id,
1121
- title: user.id === me.id ? '-' : undefined,
1122
- onClick: () => toggleDisabled(user),
1123
- }, user.disabled ? t('actionEnable') : t('actionDisable')),
1124
- user.totpEnabled ? h('button', {
1125
- className: 'um-btn', disabled: busyId === user.id,
1126
- title: t('totpResetAction'), onClick: () => resetTotp(user),
1127
- }, t('totpResetAction')) : null,
1128
- h('button', {
1129
- className: 'um-btn um-btn-danger', disabled: busyId === user.id || user.id === me.id,
1130
- onClick: () => remove(user),
1131
- }, t('actionDelete'))))))))),
1181
+ h('button', { className: 'um-btn um-btn-primary', onClick: () => setEditUser(user) }, t('actionEdit'))))))))),
1132
1182
  creating && h(UmDialog, {
1133
1183
  title: t('createUserTitle'), onClose: () => setCreating(false),
1134
1184
  footer: [
@@ -1141,14 +1191,24 @@ window.__ModuleLoader__.load({
1141
1191
  h('input', { id: 'um-new-username', className: 'um-input', value: form.username, onChange: (e) => setForm({ ...form, username: e.target.value }), required: true, autoFocus: true }),
1142
1192
  h('label', { htmlFor: 'um-new-password' }, t('colPassword')),
1143
1193
  h('input', { id: 'um-new-password', className: 'um-input', type: 'password', autoComplete: 'new-password', minLength: 6, value: form.password, onChange: (e) => setForm({ ...form, password: e.target.value }), required: true }),
1194
+ h('label', { htmlFor: 'um-new-department' }, t('colDepartment')),
1195
+ h('input', {
1196
+ id: 'um-new-department', className: 'um-input', value: form.department, maxLength: 64,
1197
+ placeholder: t('departmentPlaceholder'),
1198
+ onChange: (e) => setForm({ ...form, department: e.target.value }),
1199
+ }),
1144
1200
  h('label', { htmlFor: 'um-new-role' }, t('colRolePick')),
1145
1201
  h('select', { id: 'um-new-role', className: 'um-input', value: form.role, onChange: (e) => setForm({ ...form, role: e.target.value }) },
1146
1202
  h('option', { value: 'user' }, t('roleUser')),
1147
1203
  h('option', { value: 'admin' }, t('roleAdmin'))))),
1148
- permUser && h(PermissionsDialog, {
1149
- user: permUser,
1150
- onClose: () => setPermUser(null),
1151
- onSaved: () => { setPermUser(null); flash(t('opOk')); void load() },
1204
+ editUser && h(EditUserPage, {
1205
+ user: editUser,
1206
+ me,
1207
+ onClose: () => setEditUser(null),
1208
+ // The page closes on any write that changes the account, so the row behind
1209
+ // it is re-read instead of being reconciled field by field.
1210
+ onDone: () => { setEditUser(null); flash(t('opOk')); void load() },
1211
+ onTempPassword: (payload) => { setTemp(payload); setCopied(false) },
1152
1212
  __t: t,
1153
1213
  }),
1154
1214
  temp && h(UmDialog, {