kczx-user-management 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/client/bundle.js +226 -166
- package/client/index.js +226 -166
- package/package.json +1 -1
- package/src/gateway-core.js +33 -4
- package/src/index.js +38 -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 自身的用量投影增量,服务端按小时窗口累计并拦截 |
|
|
@@ -98,6 +99,7 @@ user-management:
|
|
|
98
99
|
sites: [] # 空 = 自动枚举本机 IP;配域名 + 证书则按 SNI 选择
|
|
99
100
|
title: 'DSH 控制台'
|
|
100
101
|
autoActivate: false # false = 自助注册需管理员启用
|
|
102
|
+
historyMaxBytes: 67108864 # 历史改写缓冲上限(字节,默认 64 MiB,最小 1 MiB)
|
|
101
103
|
```
|
|
102
104
|
|
|
103
105
|
## 数据文件
|
|
@@ -106,7 +108,7 @@ user-management:
|
|
|
106
108
|
|
|
107
109
|
| 文件 | 内容 |
|
|
108
110
|
|---|---|
|
|
109
|
-
| `users.json` | 账号(scrypt 哈希;迁移进来的账号保留 bcrypt,首次登录成功后自动升级为 scrypt
|
|
111
|
+
| `users.json` | 账号(scrypt 哈希;迁移进来的账号保留 bcrypt,首次登录成功后自动升级为 scrypt;含 `department` 所属部门) |
|
|
110
112
|
| `sessions.json` | 会话令牌(重启不掉线,7 天滑动过期) |
|
|
111
113
|
| `activity.jsonl` / `audit.jsonl` | 登录与访问台账 / 操作日志(滚动保留最近若干条) |
|
|
112
114
|
| `bans.json` | IP 封禁 |
|
|
@@ -130,7 +132,7 @@ node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --ove
|
|
|
130
132
|
## 测试
|
|
131
133
|
|
|
132
134
|
```bash
|
|
133
|
-
npm test #
|
|
135
|
+
npm test # 93 项:策略纯函数、store、API 层、以及真实网关的端到端用例
|
|
134
136
|
npm run check # 全量语法检查
|
|
135
137
|
```
|
|
136
138
|
|
|
@@ -151,7 +153,10 @@ dsh plugin --profile web remove kczx-user-management
|
|
|
151
153
|
- **门只管走门的人**:若另有插件把 dsh 宿主绑到 `0.0.0.0`(例如 dsh-lan-access),直连宿主端口即可绕过登录,
|
|
152
154
|
且**绕过流量不会出现在任何一本台账里**。要真正的强制约束,宿主端口必须保持回环。
|
|
153
155
|
- **token 配额依赖客户端上报**:浏览器侧被禁用或页面未加载时不计费(时长配额不受影响,它在服务端计时)。
|
|
154
|
-
-
|
|
156
|
+
- **会话历史要缓冲后才能改写**:历史响应是**一个 JSON 文档**(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符
|
|
157
|
+
清洗,因此有 `historyMaxBytes` 上限(默认 64 MiB)。超过上限的会话会 **fail-closed 返回 502**,UI 显示「历史加载失败」——
|
|
158
|
+
调大该值即可;1.0.0 之前用代理默认的 8 MiB,长会话必然踩中。
|
|
159
|
+
- 上游 `@weibaohui/user-management`(0.9.5)与本包 `kczx-user-management` 是两个不同的包,互不覆盖,`pnpm update` 不会把两者串在一起。
|
|
155
160
|
|
|
156
161
|
## License
|
|
157
162
|
|
package/client/bundle.js
CHANGED
|
@@ -105,8 +105,21 @@ window.__ModuleLoader__.load({
|
|
|
105
105
|
actionLogout: '退出登录',
|
|
106
106
|
createUser: '新增用户',
|
|
107
107
|
createUserTitle: '新增用户',
|
|
108
|
+
actionEdit: '编辑',
|
|
109
|
+
editUserTitle: '编辑用户',
|
|
110
|
+
sectionProfile: '基本资料',
|
|
111
|
+
sectionAccount: '账号操作',
|
|
112
|
+
accountActionsHint: '以上操作立即生效且不可撤销;角色、部门、权限与配额的改动请用右上角「保存」提交。',
|
|
113
|
+
editSelfHint: '不能修改自己的角色,也不能禁用或删除自己的账号。',
|
|
114
|
+
close: '关闭',
|
|
108
115
|
colPassword: '密码(至少 6 位)',
|
|
109
116
|
colRolePick: '角色',
|
|
117
|
+
colDepartment: '所属部门',
|
|
118
|
+
departmentPlaceholder: '所属部门(可选,最多 64 字)',
|
|
119
|
+
departmentUnset: '未设置',
|
|
120
|
+
editDepartmentHint: '留空表示清除该用户的所属部门。',
|
|
121
|
+
departmentUpdated: '所属部门已更新',
|
|
122
|
+
typeProfileChange: '资料变更',
|
|
110
123
|
submitCreate: '创建',
|
|
111
124
|
userCreated: '用户已创建',
|
|
112
125
|
actionDisable: '禁用',
|
|
@@ -184,7 +197,6 @@ window.__ModuleLoader__.load({
|
|
|
184
197
|
usageMinutesFree: '{used} 分钟',
|
|
185
198
|
usageTokens: '{used}/{limit} tok/h',
|
|
186
199
|
usageTokensFree: '{used} tok/h',
|
|
187
|
-
actionPermissions: '权限/配额',
|
|
188
200
|
permissionsTitle: '权限与配额',
|
|
189
201
|
permFolders: '工作区白名单',
|
|
190
202
|
permFoldersHint: '留空 = 允许访问所有工作区;填写后只允许这些目录下的工作区。',
|
|
@@ -276,8 +288,21 @@ window.__ModuleLoader__.load({
|
|
|
276
288
|
actionLogout: 'Sign out',
|
|
277
289
|
createUser: 'New User',
|
|
278
290
|
createUserTitle: 'New User',
|
|
291
|
+
actionEdit: 'Edit',
|
|
292
|
+
editUserTitle: 'Edit user',
|
|
293
|
+
sectionProfile: 'Profile',
|
|
294
|
+
sectionAccount: 'Account actions',
|
|
295
|
+
accountActionsHint: 'These take effect immediately and cannot be undone. Role, department, permissions and quota are written by Save (top right).',
|
|
296
|
+
editSelfHint: 'You cannot change your own role, disable, or delete your own account.',
|
|
297
|
+
close: 'Close',
|
|
279
298
|
colPassword: 'Password (min 6 chars)',
|
|
280
299
|
colRolePick: 'Role',
|
|
300
|
+
colDepartment: 'Department',
|
|
301
|
+
departmentPlaceholder: 'Department (optional, max 64 chars)',
|
|
302
|
+
departmentUnset: 'Not set',
|
|
303
|
+
editDepartmentHint: 'Leave empty to clear this account’s department.',
|
|
304
|
+
departmentUpdated: 'Department updated',
|
|
305
|
+
typeProfileChange: 'Profile change',
|
|
281
306
|
submitCreate: 'Create',
|
|
282
307
|
userCreated: 'User created',
|
|
283
308
|
actionDisable: 'Disable',
|
|
@@ -355,7 +380,6 @@ window.__ModuleLoader__.load({
|
|
|
355
380
|
usageMinutesFree: '{used} min',
|
|
356
381
|
usageTokens: '{used}/{limit} tok/h',
|
|
357
382
|
usageTokensFree: '{used} tok/h',
|
|
358
|
-
actionPermissions: 'Limits',
|
|
359
383
|
permissionsTitle: 'Permissions & Quota',
|
|
360
384
|
permFolders: 'Workspace allow-list',
|
|
361
385
|
permFoldersHint: 'Empty = every workspace is allowed; once filled, only workspaces under these folders are.',
|
|
@@ -399,6 +423,7 @@ window.__ModuleLoader__.load({
|
|
|
399
423
|
delete_user: 'typeDeleteUser',
|
|
400
424
|
access: 'typeAccess',
|
|
401
425
|
user_created: 'typeUserCreated',
|
|
426
|
+
profile_change: 'typeProfileChange',
|
|
402
427
|
user_disabled: 'typeUserDisabled',
|
|
403
428
|
user_enabled: 'typeUserEnabled',
|
|
404
429
|
ban_ip: 'typeBanIp',
|
|
@@ -870,16 +895,25 @@ window.__ModuleLoader__.load({
|
|
|
870
895
|
return bits.length === 0 ? t('permUnrestricted') : bits.join(' · ')
|
|
871
896
|
}
|
|
872
897
|
|
|
873
|
-
/**
|
|
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, {
|