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 +4 -3
- package/client/bundle.js +226 -166
- package/client/index.js +226 -166
- package/package.json +1 -1
- package/src/index.js +30 -3
- package/src/store.js +34 -2
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 #
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
876
|
-
*
|
|
877
|
-
* switches
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
.finally(() => setBusy(false))
|
|
923
|
-
}
|
|
977
|
+
onDone()
|
|
978
|
+
})
|
|
924
979
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
h('
|
|
987
|
-
|
|
988
|
-
t('
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
h('
|
|
992
|
-
|
|
993
|
-
|
|
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 [
|
|
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',
|
|
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
|
-
|
|
1149
|
-
user:
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
866
|
-
*
|
|
867
|
-
* switches
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
.finally(() => setBusy(false))
|
|
913
|
-
}
|
|
967
|
+
onDone()
|
|
968
|
+
})
|
|
914
969
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
h('
|
|
977
|
-
|
|
978
|
-
t('
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
h('
|
|
982
|
-
|
|
983
|
-
|
|
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 [
|
|
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',
|
|
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
|
-
|
|
1139
|
-
user:
|
|
1140
|
-
|
|
1141
|
-
|
|
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.
|
|
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
|
}
|