kczx-user-management 1.0.1 → 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 自身的用量投影增量,服务端按小时窗口累计并拦截 |
@@ -107,7 +108,7 @@ user-management:
107
108
 
108
109
  | 文件 | 内容 |
109
110
  |---|---|
110
- | `users.json` | 账号(scrypt 哈希;迁移进来的账号保留 bcrypt,首次登录成功后自动升级为 scrypt |
111
+ | `users.json` | 账号(scrypt 哈希;迁移进来的账号保留 bcrypt,首次登录成功后自动升级为 scrypt;含 `department` 所属部门) |
111
112
  | `sessions.json` | 会话令牌(重启不掉线,7 天滑动过期) |
112
113
  | `activity.jsonl` / `audit.jsonl` | 登录与访问台账 / 操作日志(滚动保留最近若干条) |
113
114
  | `bans.json` | IP 封禁 |
@@ -131,7 +132,7 @@ node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --ove
131
132
  ## 测试
132
133
 
133
134
  ```bash
134
- npm test # 85 项:策略纯函数、store、API 层、以及真实网关的端到端用例
135
+ npm test # 93 项:策略纯函数、store、API 层、以及真实网关的端到端用例
135
136
  npm run check # 全量语法检查
136
137
  ```
137
138
 
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, {
package/client/index.js CHANGED
@@ -95,8 +95,21 @@ const ZH = {
95
95
  actionLogout: '退出登录',
96
96
  createUser: '新增用户',
97
97
  createUserTitle: '新增用户',
98
+ actionEdit: '编辑',
99
+ editUserTitle: '编辑用户',
100
+ sectionProfile: '基本资料',
101
+ sectionAccount: '账号操作',
102
+ accountActionsHint: '以上操作立即生效且不可撤销;角色、部门、权限与配额的改动请用右上角「保存」提交。',
103
+ editSelfHint: '不能修改自己的角色,也不能禁用或删除自己的账号。',
104
+ close: '关闭',
98
105
  colPassword: '密码(至少 6 位)',
99
106
  colRolePick: '角色',
107
+ colDepartment: '所属部门',
108
+ departmentPlaceholder: '所属部门(可选,最多 64 字)',
109
+ departmentUnset: '未设置',
110
+ editDepartmentHint: '留空表示清除该用户的所属部门。',
111
+ departmentUpdated: '所属部门已更新',
112
+ typeProfileChange: '资料变更',
100
113
  submitCreate: '创建',
101
114
  userCreated: '用户已创建',
102
115
  actionDisable: '禁用',
@@ -174,7 +187,6 @@ const ZH = {
174
187
  usageMinutesFree: '{used} 分钟',
175
188
  usageTokens: '{used}/{limit} tok/h',
176
189
  usageTokensFree: '{used} tok/h',
177
- actionPermissions: '权限/配额',
178
190
  permissionsTitle: '权限与配额',
179
191
  permFolders: '工作区白名单',
180
192
  permFoldersHint: '留空 = 允许访问所有工作区;填写后只允许这些目录下的工作区。',
@@ -266,8 +278,21 @@ const EN = {
266
278
  actionLogout: 'Sign out',
267
279
  createUser: 'New User',
268
280
  createUserTitle: 'New User',
281
+ actionEdit: 'Edit',
282
+ editUserTitle: 'Edit user',
283
+ sectionProfile: 'Profile',
284
+ sectionAccount: 'Account actions',
285
+ accountActionsHint: 'These take effect immediately and cannot be undone. Role, department, permissions and quota are written by Save (top right).',
286
+ editSelfHint: 'You cannot change your own role, disable, or delete your own account.',
287
+ close: 'Close',
269
288
  colPassword: 'Password (min 6 chars)',
270
289
  colRolePick: 'Role',
290
+ colDepartment: 'Department',
291
+ departmentPlaceholder: 'Department (optional, max 64 chars)',
292
+ departmentUnset: 'Not set',
293
+ editDepartmentHint: 'Leave empty to clear this account’s department.',
294
+ departmentUpdated: 'Department updated',
295
+ typeProfileChange: 'Profile change',
271
296
  submitCreate: 'Create',
272
297
  userCreated: 'User created',
273
298
  actionDisable: 'Disable',
@@ -345,7 +370,6 @@ const EN = {
345
370
  usageMinutesFree: '{used} min',
346
371
  usageTokens: '{used}/{limit} tok/h',
347
372
  usageTokensFree: '{used} tok/h',
348
- actionPermissions: 'Limits',
349
373
  permissionsTitle: 'Permissions & Quota',
350
374
  permFolders: 'Workspace allow-list',
351
375
  permFoldersHint: 'Empty = every workspace is allowed; once filled, only workspaces under these folders are.',
@@ -389,6 +413,7 @@ const TYPE_LABELS = {
389
413
  delete_user: 'typeDeleteUser',
390
414
  access: 'typeAccess',
391
415
  user_created: 'typeUserCreated',
416
+ profile_change: 'typeProfileChange',
392
417
  user_disabled: 'typeUserDisabled',
393
418
  user_enabled: 'typeUserEnabled',
394
419
  ban_ip: 'typeBanIp',
@@ -860,16 +885,25 @@ function permissionSummary(user, t) {
860
885
  return bits.length === 0 ? t('permUnrestricted') : bits.join(' · ')
861
886
  }
862
887
 
863
- /** Per-user permissions & quota editor (admin only).
888
+ /** Full-page account editor (admin only): profile, account operations and the
889
+ * permission/quota model that used to live in dsh-passwords, on one surface.
864
890
  *
865
- * This is the account model that used to live in dsh-passwords: workspace
866
- * scope, an hourly token cap, daily minutes, a sandbox floor, upload/git
867
- * switches and the policy ban. The store's reading is surfaced verbatim in the
868
- * form: an empty folder list means every workspace, an empty limit means no
869
- * cap so clearing a field is a visible, deliberate act rather than a silent
870
- * default. */
871
- function PermissionsDialog({ user, onClose, onSaved, __t: t }) {
891
+ * Editing and acting are deliberately separated. Everything that is just state
892
+ * (department, role, workspace scope, quotas, sandbox floor, upload/git
893
+ * switches, ban, note) is staged in the form and written by 保存. Everything
894
+ * that takes effect the moment it is pressed and is hard to walk back (reset
895
+ * password, reset TOTP, disable, delete) stays an explicit button with its own
896
+ * confirm: folding those into a save button would hide them from the operator
897
+ * reviewing the form before pressing it.
898
+ *
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. */
902
+ function EditUserPage({ user, me, onClose, onDone, onTempPassword, __t: t }) {
872
903
  const p = user.permissions || {}
904
+ const isSelf = user.id === me.id
905
+ const [department, setDepartment] = useState(user.department || '')
906
+ const [role, setRole] = useState(user.role)
873
907
  const [folders, setFolders] = useState(Array.isArray(p.allowedFolders) ? p.allowedFolders.slice() : [])
874
908
  const [draft, setDraft] = useState('')
875
909
  const [tokenLimit, setTokenLimit] = useState(p.hourlyTokenLimit == null ? '' : String(p.hourlyTokenLimit))
@@ -882,6 +916,13 @@ function PermissionsDialog({ user, onClose, onSaved, __t: t }) {
882
916
  const [busy, setBusy] = useState(false)
883
917
  const [err, setErr] = useState('')
884
918
 
919
+ // Same walk-back affordance as the console page this opens from: Escape closes.
920
+ useEffect(() => {
921
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
922
+ document.addEventListener('keydown', onKey)
923
+ return () => document.removeEventListener('keydown', onKey)
924
+ }, [onClose])
925
+
885
926
  const addFolder = () => {
886
927
  const value = draft.trim()
887
928
  if (value === '' || folders.includes(value)) { setDraft(''); return }
@@ -890,11 +931,27 @@ function PermissionsDialog({ user, onClose, onSaved, __t: t }) {
890
931
  }
891
932
  const toLimit = (value) => (String(value).trim() === '' ? null : Number(value))
892
933
 
893
- const submit = () => {
934
+ /** One write at a time, with the failure kept on the page rather than flashed
935
+ * away — an edit this wide needs the error next to the fields it concerns. */
936
+ const run = (fn) => {
894
937
  if (busy) return
895
938
  setBusy(true)
896
939
  setErr('')
897
- api(`/users/${user.id}/permissions`, {
940
+ Promise.resolve().then(fn).catch((e) => setErr(String(e && e.message))).finally(() => setBusy(false))
941
+ }
942
+
943
+ const save = () => run(async () => {
944
+ // Role first: it is the one staged edit behind a confirm, so cancelling
945
+ // that confirm leaves nothing else written.
946
+ if (role !== user.role) {
947
+ const label = role === 'admin' ? t('roleAdmin') : t('roleUser')
948
+ if (!window.confirm(interpolate(t('roleConfirm'), { name: user.username, role: label }))) return
949
+ await api(`/users/${user.id}/role`, { method: 'POST', body: { role } })
950
+ }
951
+ if (department !== (user.department || '')) {
952
+ await api(`/users/${user.id}/profile`, { method: 'PATCH', body: { department } })
953
+ }
954
+ await api(`/users/${user.id}/permissions`, {
898
955
  method: 'PUT',
899
956
  body: {
900
957
  allowedFolders: folders,
@@ -907,153 +964,163 @@ function PermissionsDialog({ user, onClose, onSaved, __t: t }) {
907
964
  note,
908
965
  },
909
966
  })
910
- .then(() => { onSaved() })
911
- .catch((e) => setErr(String(e && e.message)))
912
- .finally(() => setBusy(false))
913
- }
967
+ onDone()
968
+ })
914
969
 
915
- return h(UmDialog, {
916
- title: `${t('permissionsTitle')} · ${user.username}`,
917
- onClose,
918
- footer: [
919
- h('button', { className: 'um-btn', onClick: onClose }, '✕'),
920
- h('button', { className: 'um-btn um-btn-primary', onClick: submit, disabled: busy }, t('save')),
921
- ],
922
- },
923
- err && h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, marginBottom: 6 } }, err),
924
- // Setting a limit is a judgement about consumption, so show it: an
925
- // administrator otherwise has to guess what "100k tokens an hour" means for
926
- // this particular account.
927
- usageSummary(user, t)
928
- ? h('div', { className: 'um-muted', style: { fontSize: 11, marginBottom: 8 } }, interpolate(t('usageToday'), { summary: usageSummary(user, t) }))
929
- : null,
930
- h('div', { className: 'um-form', style: { maxWidth: 'none' } },
931
- h('label', null, t('permFolders')),
932
- h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
933
- folders.length === 0
934
- ? h('div', { className: 'um-empty' }, t('permFoldersAll'))
935
- : folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
936
- h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
937
- h('button', {
938
- className: 'um-btn um-btn-danger', style: { flex: 'none' },
939
- onClick: () => setFolders(folders.filter((_, i) => i !== index)),
940
- }, t('remove')))),
941
- h('div', { className: 'um-row' },
942
- h('input', {
943
- className: 'um-input', style: { flex: 1 }, placeholder: t('permFolderPlaceholder'),
944
- value: draft, onChange: (e) => setDraft(e.target.value),
945
- onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
946
- }),
947
- h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))),
970
+ const resetPwd = () => run(async () => {
971
+ if (!window.confirm(interpolate(t('resetPwdConfirm'), { name: user.username }))) return
972
+ const d = await api(`/users/${user.id}/reset-password`, { method: 'POST' })
973
+ onTempPassword({ name: user.username, pwd: d.tempPassword })
974
+ })
948
975
 
949
- h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
950
- h('input', {
951
- id: 'um-perm-tokens', className: 'um-input', inputMode: 'numeric',
952
- placeholder: t('permUnlimited'), value: tokenLimit, onChange: (e) => setTokenLimit(e.target.value),
953
- }),
954
- h('label', { htmlFor: 'um-perm-minutes' }, t('permMinuteLimit')),
955
- h('input', {
956
- id: 'um-perm-minutes', className: 'um-input', inputMode: 'numeric',
957
- placeholder: t('permUnlimited'), value: minuteLimit, onChange: (e) => setMinuteLimit(e.target.value),
958
- }),
976
+ const resetTotp = () => run(async () => {
977
+ if (!window.confirm(interpolate(t('totpResetConfirm'), { name: user.username }))) return
978
+ await api(`/users/${user.id}/reset-totp`, { method: 'POST' })
979
+ onDone()
980
+ })
959
981
 
960
- h('label', { htmlFor: 'um-perm-sandbox' }, t('permSandbox')),
961
- h('select', {
962
- id: 'um-perm-sandbox', className: 'um-input', value: sandbox,
963
- onChange: (e) => setSandbox(e.target.value),
964
- },
965
- h('option', { value: '' }, t('permSandboxNone')),
966
- h('option', { value: 'read-only' }, t('permSandboxReadOnly')),
967
- h('option', { value: 'workspace-write' }, t('permSandboxWorkspace')),
968
- h('option', { value: 'danger-full-access' }, t('permSandboxFull'))),
969
-
970
- h('label', { className: 'um-row', style: { gap: 6 } },
971
- h('input', { type: 'checkbox', checked: allowUpload, onChange: (e) => setAllowUpload(e.target.checked) }),
972
- t('permAllowUpload')),
973
- h('label', { className: 'um-row', style: { gap: 6 } },
974
- h('input', { type: 'checkbox', checked: allowGit, onChange: (e) => setAllowGit(e.target.checked) }),
975
- t('permAllowGit')),
976
- h('label', { className: 'um-row', style: { gap: 6 } },
977
- h('input', { type: 'checkbox', checked: banned, onChange: (e) => setBanned(e.target.checked) }),
978
- t('permBanned')),
979
-
980
- h('label', { htmlFor: 'um-perm-note' }, t('permNote')),
981
- h('input', {
982
- id: 'um-perm-note', className: 'um-input', maxLength: 200,
983
- value: note, onChange: (e) => setNote(e.target.value),
984
- })))
982
+ const toggleDisabled = () => run(async () => {
983
+ const disabling = !user.disabled
984
+ const key = disabling ? 'disableConfirm' : 'enableConfirm'
985
+ if (!window.confirm(interpolate(t(key), { name: user.username }))) return
986
+ await api(`/users/${user.id}/disabled`, { method: 'POST', body: { disabled: disabling } })
987
+ onDone()
988
+ })
989
+
990
+ const remove = () => run(async () => {
991
+ if (!window.confirm(interpolate(t('deleteConfirm'), { name: user.username }))) return
992
+ await api(`/users/${user.id}`, { method: 'DELETE' })
993
+ onDone()
994
+ })
995
+
996
+ return h('div', { className: 'um-page-backdrop', role: 'dialog', 'aria-label': t('editUserTitle') },
997
+ h('div', { className: 'um-page-head' },
998
+ h('h3', { className: 'um-page-title' }, `${t('editUserTitle')} · ${user.username}`),
999
+ h('div', { className: 'um-row' },
1000
+ h('button', { className: 'um-btn', onClick: onClose }, t('close')),
1001
+ h('button', { className: 'um-btn um-btn-primary', disabled: busy, onClick: save }, t('save')))),
1002
+ h('div', { className: 'um-page-body' },
1003
+ h('div', { className: 'um-page-inner' },
1004
+ err && h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, marginBottom: 8 } }, err),
1005
+
1006
+ h('div', { className: 'um-card', style: { marginBottom: 14 } },
1007
+ h('h3', { className: 'um-title' }, t('sectionProfile')),
1008
+ h('div', { className: 'um-form', style: { maxWidth: 420 } },
1009
+ h('label', { htmlFor: 'um-edit-username' }, t('colUser')),
1010
+ h('input', { id: 'um-edit-username', className: 'um-input', value: user.username, disabled: true }),
1011
+ h('label', { htmlFor: 'um-edit-department' }, t('colDepartment')),
1012
+ h('input', {
1013
+ id: 'um-edit-department', className: 'um-input', value: department, maxLength: 64,
1014
+ placeholder: t('departmentPlaceholder'),
1015
+ onChange: (e) => setDepartment(e.target.value),
1016
+ }),
1017
+ h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('editDepartmentHint')),
1018
+ h('label', { htmlFor: 'um-edit-role' }, t('colRolePick')),
1019
+ h('select', {
1020
+ id: 'um-edit-role', className: 'um-input', value: role, disabled: isSelf,
1021
+ onChange: (e) => setRole(e.target.value),
1022
+ },
1023
+ h('option', { value: 'user' }, t('roleUser')),
1024
+ h('option', { value: 'admin' }, t('roleAdmin'))),
1025
+ isSelf ? h('div', { className: 'um-muted', style: { fontSize: 11 } }, t('editSelfHint')) : null)),
1026
+
1027
+ h('div', { className: 'um-card', style: { marginBottom: 14 } },
1028
+ h('h3', { className: 'um-title' }, t('sectionAccount')),
1029
+ h('div', { className: 'um-row', style: { flexWrap: 'wrap' } },
1030
+ h('button', { className: 'um-btn', disabled: busy, onClick: resetPwd }, t('actionResetPwd')),
1031
+ user.totpEnabled
1032
+ ? h('button', { className: 'um-btn', disabled: busy, onClick: resetTotp }, t('totpResetAction'))
1033
+ : null,
1034
+ h('button', {
1035
+ className: user.disabled ? 'um-btn' : 'um-btn um-btn-danger',
1036
+ disabled: busy || isSelf, title: isSelf ? '-' : undefined, onClick: toggleDisabled,
1037
+ }, user.disabled ? t('actionEnable') : t('actionDisable')),
1038
+ h('button', {
1039
+ className: 'um-btn um-btn-danger',
1040
+ disabled: busy || isSelf, title: isSelf ? '-' : undefined, onClick: remove,
1041
+ }, t('actionDelete'))),
1042
+ h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: 8 } }, t('accountActionsHint'))),
1043
+
1044
+ h('div', { className: 'um-card' },
1045
+ h('h3', { className: 'um-title' }, t('permissionsTitle')),
1046
+ // Setting a limit is a judgement about consumption, so show it: an
1047
+ // administrator otherwise has to guess what "100k tokens an hour"
1048
+ // means for this particular account.
1049
+ usageSummary(user, t)
1050
+ ? h('div', { className: 'um-muted', style: { fontSize: 11, marginBottom: 8 } }, interpolate(t('usageToday'), { summary: usageSummary(user, t) }))
1051
+ : null,
1052
+ h('div', { className: 'um-form', style: { maxWidth: 'none' } },
1053
+ h('label', null, t('permFolders')),
1054
+ h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
1055
+ folders.length === 0
1056
+ ? h('div', { className: 'um-empty' }, t('permFoldersAll'))
1057
+ : folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
1058
+ h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
1059
+ h('button', {
1060
+ className: 'um-btn um-btn-danger', style: { flex: 'none' },
1061
+ onClick: () => setFolders(folders.filter((_, i) => i !== index)),
1062
+ }, t('remove')))),
1063
+ h('div', { className: 'um-row' },
1064
+ h('input', {
1065
+ className: 'um-input', style: { flex: 1 }, placeholder: t('permFolderPlaceholder'),
1066
+ value: draft, onChange: (e) => setDraft(e.target.value),
1067
+ onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
1068
+ }),
1069
+ h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))),
1070
+
1071
+ h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
1072
+ h('input', {
1073
+ id: 'um-perm-tokens', className: 'um-input', inputMode: 'numeric',
1074
+ placeholder: t('permUnlimited'), value: tokenLimit, onChange: (e) => setTokenLimit(e.target.value),
1075
+ }),
1076
+ h('label', { htmlFor: 'um-perm-minutes' }, t('permMinuteLimit')),
1077
+ h('input', {
1078
+ id: 'um-perm-minutes', className: 'um-input', inputMode: 'numeric',
1079
+ placeholder: t('permUnlimited'), value: minuteLimit, onChange: (e) => setMinuteLimit(e.target.value),
1080
+ }),
1081
+
1082
+ h('label', { htmlFor: 'um-perm-sandbox' }, t('permSandbox')),
1083
+ h('select', {
1084
+ id: 'um-perm-sandbox', className: 'um-input', value: sandbox,
1085
+ onChange: (e) => setSandbox(e.target.value),
1086
+ },
1087
+ h('option', { value: '' }, t('permSandboxNone')),
1088
+ h('option', { value: 'read-only' }, t('permSandboxReadOnly')),
1089
+ h('option', { value: 'workspace-write' }, t('permSandboxWorkspace')),
1090
+ h('option', { value: 'danger-full-access' }, t('permSandboxFull'))),
1091
+
1092
+ h('label', { className: 'um-row', style: { gap: 6 } },
1093
+ h('input', { type: 'checkbox', checked: allowUpload, onChange: (e) => setAllowUpload(e.target.checked) }),
1094
+ t('permAllowUpload')),
1095
+ h('label', { className: 'um-row', style: { gap: 6 } },
1096
+ h('input', { type: 'checkbox', checked: allowGit, onChange: (e) => setAllowGit(e.target.checked) }),
1097
+ t('permAllowGit')),
1098
+ h('label', { className: 'um-row', style: { gap: 6 } },
1099
+ h('input', { type: 'checkbox', checked: banned, onChange: (e) => setBanned(e.target.checked) }),
1100
+ t('permBanned')),
1101
+
1102
+ h('label', { htmlFor: 'um-perm-note' }, t('permNote')),
1103
+ h('input', {
1104
+ id: 'um-perm-note', className: 'um-input', maxLength: 200,
1105
+ value: note, onChange: (e) => setNote(e.target.value),
1106
+ }))))))
985
1107
  }
986
1108
 
987
1109
  function UsersTab({ me, __t: t, flash }) {
988
1110
  const [users, setUsers] = useState(null)
989
1111
  const [temp, setTemp] = useState(null) // {name, pwd}
990
- const [busyId, setBusyId] = useState(null)
991
1112
  const [copied, setCopied] = useState(false)
992
1113
  const [creating, setCreating] = useState(false)
993
- const [permUser, setPermUser] = useState(null)
994
- const [form, setForm] = useState({ username: '', password: '', role: 'user' })
1114
+ const [editUser, setEditUser] = useState(null)
1115
+ const [form, setForm] = useState({ username: '', password: '', role: 'user', department: '' })
995
1116
 
996
1117
  const load = () => api('/users').then((d) => setUsers(d.users)).catch((e) => flash(t('failed') + e.message))
997
1118
  useEffect(() => { load() }, [])
998
1119
 
999
- const act = async (user, fn) => {
1000
- setBusyId(user.id)
1001
- try { await fn() } catch (e) { flash(t('failed') + e.message) } finally { setBusyId(null) }
1002
- }
1003
-
1004
- const resetPwd = (user) => {
1005
- if (!window.confirm(interpolate(t('resetPwdConfirm'), { name: user.username }))) return
1006
- act(user, async () => {
1007
- const d = await api(`/users/${user.id}/reset-password`, { method: 'POST' })
1008
- setTemp({ name: user.username, pwd: d.tempPassword })
1009
- setCopied(false)
1010
- })
1011
- }
1012
-
1013
- const changeRole = (user) => {
1014
- const nextRole = user.role === 'admin' ? 'user' : 'admin'
1015
- const label = nextRole === 'admin' ? t('roleAdmin') : t('roleUser')
1016
- if (!window.confirm(interpolate(t('roleConfirm'), { name: user.username, role: label }))) return
1017
- act(user, async () => {
1018
- await api(`/users/${user.id}/role`, { method: 'POST', body: { role: nextRole } })
1019
- flash(t('opOk'))
1020
- await load()
1021
- })
1022
- }
1023
-
1024
- const toggleDisabled = (user) => {
1025
- const disabling = !user.disabled
1026
- const key = disabling ? 'disableConfirm' : 'enableConfirm'
1027
- if (!window.confirm(interpolate(t(key), { name: user.username }))) return
1028
- act(user, async () => {
1029
- await api(`/users/${user.id}/disabled`, { method: 'POST', body: { disabled: disabling } })
1030
- flash(t('opOk'))
1031
- await load()
1032
- })
1033
- }
1034
-
1035
- const remove = (user) => {
1036
- if (!window.confirm(interpolate(t('deleteConfirm'), { name: user.username }))) return
1037
- act(user, async () => {
1038
- await api(`/users/${user.id}`, { method: 'DELETE' })
1039
- flash(t('opOk'))
1040
- await load()
1041
- })
1042
- }
1043
-
1044
- const resetTotp = (user) => {
1045
- if (!window.confirm(interpolate(t('totpResetConfirm'), { name: user.username }))) return
1046
- act(user, async () => {
1047
- await api(`/users/${user.id}/reset-totp`, { method: 'POST' })
1048
- flash(t('totpResetDone'))
1049
- await load()
1050
- })
1051
- }
1052
-
1053
1120
  const submitCreate = (e) => {
1054
1121
  e.preventDefault()
1055
1122
  api('/users', { method: 'POST', body: form })
1056
- .then(() => { setCreating(false); setForm({ username: '', password: '', role: 'user' }); flash(t('userCreated')); return load() })
1123
+ .then(() => { setCreating(false); setForm({ username: '', password: '', role: 'user', department: '' }); flash(t('userCreated')); return load() })
1057
1124
  .catch((err) => flash(t('failed') + err.message))
1058
1125
  }
1059
1126
 
@@ -1075,6 +1142,7 @@ function UsersTab({ me, __t: t, flash }) {
1075
1142
  h('table', { className: 'um-table' },
1076
1143
  h('thead', null, h('tr', null,
1077
1144
  h('th', null, t('colUser')),
1145
+ h('th', null, t('colDepartment')),
1078
1146
  h('th', null, t('colRole')),
1079
1147
  h('th', null, t('colCreatedAt')),
1080
1148
  h('th', null, t('colLastLogin')),
@@ -1082,6 +1150,7 @@ function UsersTab({ me, __t: t, flash }) {
1082
1150
  h('th', null, ''))),
1083
1151
  h('tbody', null, users.map((user) => h('tr', { key: user.id, style: user.disabled ? { opacity: 0.55 } : undefined },
1084
1152
  h('td', null, user.username, user.id === me.id ? t('you') : null),
1153
+ h('td', { className: user.department ? undefined : 'um-muted' }, user.department || t('departmentUnset')),
1085
1154
  h('td', null, h('span', { className: 'um-row', style: { gap: 4 } },
1086
1155
  h(RoleBadge, { role: user.role, __t: t }),
1087
1156
  user.disabled ? h('span', { className: 'um-badge um-badge-err' }, t('badgeDisabled')) : null,
@@ -1099,26 +1168,7 @@ function UsersTab({ me, __t: t, flash }) {
1099
1168
  ? h('div', { className: 'um-muted', style: { fontSize: 11, whiteSpace: 'nowrap' } }, usageSummary(user, t))
1100
1169
  : null)),
1101
1170
  h('td', null, h('div', { className: 'um-row', style: { justifyContent: 'flex-end' } },
1102
- h('button', { className: 'um-btn', disabled: busyId === user.id, onClick: () => setPermUser(user) }, t('actionPermissions')),
1103
- h('button', { className: 'um-btn', disabled: busyId === user.id, onClick: () => resetPwd(user) }, t('actionResetPwd')),
1104
- h('button', {
1105
- className: 'um-btn', disabled: busyId === user.id || user.id === me.id,
1106
- title: user.id === me.id ? '-' : undefined, onClick: () => changeRole(user),
1107
- }, user.role === 'admin' ? t('actionSetUser') : t('actionSetAdmin')),
1108
- h('button', {
1109
- className: user.disabled ? 'um-btn' : 'um-btn um-btn-danger',
1110
- disabled: busyId === user.id || user.id === me.id,
1111
- title: user.id === me.id ? '-' : undefined,
1112
- onClick: () => toggleDisabled(user),
1113
- }, user.disabled ? t('actionEnable') : t('actionDisable')),
1114
- user.totpEnabled ? h('button', {
1115
- className: 'um-btn', disabled: busyId === user.id,
1116
- title: t('totpResetAction'), onClick: () => resetTotp(user),
1117
- }, t('totpResetAction')) : null,
1118
- h('button', {
1119
- className: 'um-btn um-btn-danger', disabled: busyId === user.id || user.id === me.id,
1120
- onClick: () => remove(user),
1121
- }, t('actionDelete'))))))))),
1171
+ h('button', { className: 'um-btn um-btn-primary', onClick: () => setEditUser(user) }, t('actionEdit'))))))))),
1122
1172
  creating && h(UmDialog, {
1123
1173
  title: t('createUserTitle'), onClose: () => setCreating(false),
1124
1174
  footer: [
@@ -1131,14 +1181,24 @@ function UsersTab({ me, __t: t, flash }) {
1131
1181
  h('input', { id: 'um-new-username', className: 'um-input', value: form.username, onChange: (e) => setForm({ ...form, username: e.target.value }), required: true, autoFocus: true }),
1132
1182
  h('label', { htmlFor: 'um-new-password' }, t('colPassword')),
1133
1183
  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 }),
1184
+ h('label', { htmlFor: 'um-new-department' }, t('colDepartment')),
1185
+ h('input', {
1186
+ id: 'um-new-department', className: 'um-input', value: form.department, maxLength: 64,
1187
+ placeholder: t('departmentPlaceholder'),
1188
+ onChange: (e) => setForm({ ...form, department: e.target.value }),
1189
+ }),
1134
1190
  h('label', { htmlFor: 'um-new-role' }, t('colRolePick')),
1135
1191
  h('select', { id: 'um-new-role', className: 'um-input', value: form.role, onChange: (e) => setForm({ ...form, role: e.target.value }) },
1136
1192
  h('option', { value: 'user' }, t('roleUser')),
1137
1193
  h('option', { value: 'admin' }, t('roleAdmin'))))),
1138
- permUser && h(PermissionsDialog, {
1139
- user: permUser,
1140
- onClose: () => setPermUser(null),
1141
- onSaved: () => { setPermUser(null); flash(t('opOk')); void load() },
1194
+ editUser && h(EditUserPage, {
1195
+ user: editUser,
1196
+ me,
1197
+ onClose: () => setEditUser(null),
1198
+ // The page closes on any write that changes the account, so the row behind
1199
+ // it is re-read instead of being reconciled field by field.
1200
+ onDone: () => { setEditUser(null); flash(t('opOk')); void load() },
1201
+ onTempPassword: (payload) => { setTemp(payload); setCopied(false) },
1142
1202
  __t: t,
1143
1203
  }),
1144
1204
  temp && h(UmDialog, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kczx-user-management",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。",
6
6
  "license": "MIT",
package/src/index.js CHANGED
@@ -535,8 +535,8 @@ async function handleApi(req, res, deps) {
535
535
  if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
536
536
  const body = await readJsonBody(req)
537
537
  try {
538
- const created = await store.createUser({ username: body.username, password: body.password, role: body.role || 'user' })
539
- await store.appendActivity({ type: 'user_created', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `created=${created.username} role=${created.role}` })
538
+ const created = await store.createUser({ username: body.username, password: body.password, role: body.role || 'user', department: body.department })
539
+ await store.appendActivity({ type: 'user_created', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `created=${created.username} role=${created.role}${created.department ? ` dept=${created.department}` : ''}` })
540
540
  return sendJson(res, 200, { user: created })
541
541
  } catch (error) {
542
542
  if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
@@ -601,7 +601,7 @@ async function handleApi(req, res, deps) {
601
601
  return sendJson(res, 405, { error: 'method not allowed' })
602
602
  }
603
603
 
604
- const adminMatch = /^\/users\/([A-Za-z0-9_-]+)(?:\/(reset-password|reset-totp|role))?$/.exec(apiPath)
604
+ const adminMatch = /^\/users\/([A-Za-z0-9_-]+)(?:\/(reset-password|reset-totp|role|profile))?$/.exec(apiPath)
605
605
 
606
606
  if (adminMatch && method === 'DELETE' && !adminMatch[2]) {
607
607
  const admin = await requireAdmin()
@@ -650,6 +650,33 @@ async function handleApi(req, res, deps) {
650
650
  return sendJson(res, 200, { user: store.publicUser(target) })
651
651
  }
652
652
 
653
+ // Profile metadata that is not a permission — today only the department
654
+ // label. Deliberately a separate endpoint: it grants nothing, so it must not
655
+ // be reachable through the permissions patch.
656
+ if (adminMatch && adminMatch[2] === 'profile' && (method === 'PUT' || method === 'PATCH')) {
657
+ const admin = await requireAdmin()
658
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
659
+ const target = store.findUser(adminMatch[1])
660
+ if (!target) return sendJson(res, 404, { error: '用户不存在' })
661
+ const body = await readJsonBody(req)
662
+ const before = target.department || ''
663
+ try {
664
+ await store.setDepartment(target, body.department)
665
+ } catch (error) {
666
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
667
+ throw error
668
+ }
669
+ const after = target.department || ''
670
+ if (after !== before) {
671
+ await store.appendActivity({
672
+ type: 'profile_change',
673
+ username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req),
674
+ detail: `target=${target.username} department=${before || '(none)'}→${after || '(none)'}`,
675
+ })
676
+ }
677
+ return sendJson(res, 200, { user: store.publicUser(target) })
678
+ }
679
+
653
680
  if (adminMatch && adminMatch[2] === 'role' && method === 'POST') {
654
681
  const admin = await requireAdmin()
655
682
  if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
package/src/store.js CHANGED
@@ -141,6 +141,7 @@ const MAX_AUDIT_LINES = 5000
141
141
  const USERNAME_RE = /^[a-zA-Z0-9_-]{2,32}$/
142
142
  const MIN_PASSWORD_LEN = 6
143
143
  const ACTIVITY_LIMIT_DEFAULT = 200
144
+ const MAX_DEPARTMENT_LEN = 64
144
145
 
145
146
  class StoreError extends Error {
146
147
  constructor(code, message) {
@@ -162,6 +163,19 @@ function isValidPassword(pw) {
162
163
  return typeof pw === 'string' && pw.length >= MIN_PASSWORD_LEN && pw.length <= 256
163
164
  }
164
165
 
166
+ /**
167
+ * Department labels are free text an admin types, so they are trimmed and
168
+ * stripped of control characters instead of pattern-matched. The length cap is
169
+ * enforced rather than silently truncating: a label that does not fit should be
170
+ * reported, not quietly mangled.
171
+ */
172
+ function normalizeDepartment(value) {
173
+ if (value === undefined || value === null) return ''
174
+ const text = String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim()
175
+ if (text.length > MAX_DEPARTMENT_LEN) throw new StoreError('bad_department', `所属部门最多 ${MAX_DEPARTMENT_LEN} 字`)
176
+ return text
177
+ }
178
+
165
179
  async function hashPassword(password, salt) {
166
180
  const useSalt = salt || randomBytes(16).toString('hex')
167
181
  const derived = await scrypt(password, useSalt, KEY_LEN, { N: SCRYPT_COST, r: 8, p: 1 })
@@ -402,6 +416,7 @@ function createStore({ home, now = () => Date.now() } = {}) {
402
416
  if (!user) return null
403
417
  return {
404
418
  id: user.id, username: user.username, role: user.role,
419
+ department: user.department || '',
405
420
  disabled: !!user.disabled,
406
421
  totpEnabled: !!user.totpSecret,
407
422
  createdAt: user.createdAt, lastLoginAt: user.lastLoginAt || null,
@@ -422,7 +437,7 @@ function createStore({ home, now = () => Date.now() } = {}) {
422
437
  return usersDoc.users.filter((u) => u.role === 'admin').length
423
438
  }
424
439
 
425
- async function createUser({ username, password, role = 'user', disabled = false, permissions }) {
440
+ async function createUser({ username, password, role = 'user', disabled = false, permissions, department }) {
426
441
  if (!isValidUsername(username)) throw new StoreError('bad_username', '用户名需 2-32 位,仅限字母/数字/下划线/连字符')
427
442
  if (!isValidPassword(password)) throw new StoreError('bad_password', `密码至少 ${MIN_PASSWORD_LEN} 位`)
428
443
  if (findUserByUsername(username)) throw new StoreError('duplicate', '用户名已存在')
@@ -432,6 +447,7 @@ function createStore({ home, now = () => Date.now() } = {}) {
432
447
  id: `u_${++usersDoc.seq}_${randomToken(6)}`,
433
448
  username: String(username),
434
449
  role,
450
+ department: normalizeDepartment(department),
435
451
  salt,
436
452
  passHash,
437
453
  createdAt: now(),
@@ -462,6 +478,20 @@ function createStore({ home, now = () => Date.now() } = {}) {
462
478
  return publicUser(user)
463
479
  }
464
480
 
481
+ /**
482
+ * Set (or clear) an account's department label.
483
+ *
484
+ * Profile metadata only: it grants nothing, which is why it is a separate
485
+ * setter and not a key in the permissions patch — a client that sends it to
486
+ * /permissions must not be able to smuggle it into the enforcement shape.
487
+ */
488
+ async function setDepartment(user, department) {
489
+ if (!user) throw new StoreError('not_found', '用户不存在')
490
+ user.department = normalizeDepartment(department)
491
+ await persistUsers()
492
+ return publicUser(user)
493
+ }
494
+
465
495
  /**
466
496
  * Create an account from an exported record, preserving its existing password
467
497
  * hash verbatim (see verifyPassword for how a foreign algorithm is handled).
@@ -1020,7 +1050,7 @@ function createStore({ home, now = () => Date.now() } = {}) {
1020
1050
  // lifecycle
1021
1051
  load,
1022
1052
  // users
1023
- createUser, verifyLogin, checkLogin, setPassword, setRole, setDisabled, removeUser, touchLogin,
1053
+ createUser, verifyLogin, checkLogin, setPassword, setRole, setDisabled, setDepartment, removeUser, touchLogin,
1024
1054
  listUsers, findUser, findUserByUsername, countAdmins, roleForNextRegistration, publicUser,
1025
1055
  setPermissions, normalizePermissions, isWorkspaceRestricted, DENY_ALL_WORKSPACES, importUser,
1026
1056
  // totp
@@ -1068,5 +1098,7 @@ export {
1068
1098
  MAX_AUDIT_LINES,
1069
1099
  USERNAME_RE,
1070
1100
  MIN_PASSWORD_LEN,
1101
+ MAX_DEPARTMENT_LEN,
1102
+ normalizeDepartment,
1071
1103
  ACTIVITY_LIMIT_DEFAULT,
1072
1104
  }