kczx-user-management 1.0.0
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/CUTOVER-RUNBOOK.md +73 -0
- package/MERGED-DEPLOYMENT.md +100 -0
- package/README.md +158 -0
- package/client/bundle.js +1799 -0
- package/client/index.js +1785 -0
- package/client/package.json +3 -0
- package/cordis.patch.yml +13 -0
- package/docs/FAQ.md +95 -0
- package/docs/demo-totp-dark.gif +0 -0
- package/docs/demo-totp-light.gif +0 -0
- package/docs/demo.gif +0 -0
- package/package.json +80 -0
- package/scripts/build-client.mjs +42 -0
- package/scripts/ensure-deps.mjs +50 -0
- package/scripts/import-from-dsh-passwords.mjs +111 -0
- package/src/certs.js +89 -0
- package/src/gate.js +162 -0
- package/src/gateway-core.js +769 -0
- package/src/index.js +1358 -0
- package/src/login-page.js +184 -0
- package/src/permissions.js +477 -0
- package/src/proxy.js +474 -0
- package/src/store.js +1072 -0
- package/src/totp.js +136 -0
- package/src/trusted-identity.js +143 -0
- package/src/vendor/qrcode-generator.js +2289 -0
package/client/bundle.js
ADDED
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
/* Generated from client/index.js by scripts/build-client.mjs — do not edit by hand.
|
|
2
|
+
* Regenerate with: npm run build:client
|
|
3
|
+
*/
|
|
4
|
+
window.__ModuleLoader__.load({
|
|
5
|
+
id: "kczx-user-management",
|
|
6
|
+
factory: (require) => {
|
|
7
|
+
var module = { exports: {} }
|
|
8
|
+
var exports = module.exports
|
|
9
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
|
|
10
|
+
var React = require("react")
|
|
11
|
+
/**
|
|
12
|
+
* dsh-plugin-user-management - Browser half.
|
|
13
|
+
*
|
|
14
|
+
* One React surface: the settings-page section (family convention — the
|
|
15
|
+
* only management entry). Admins get the full user table plus the login /
|
|
16
|
+
* access ledgers; plain users get their own profile card, password change
|
|
17
|
+
* and their own login records. All colors come from the ui-theme
|
|
18
|
+
* `--dsw-*` token layers so light/dark follows the shell; all copy comes
|
|
19
|
+
* from the locale registry (`zh`/`en`). No hardcoded colors, no ad-hoc copy.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// React is a loader platform module. Under plain Node (contract tests) a
|
|
23
|
+
// minimal createElement/hook shim keeps the source loadable for assertions.
|
|
24
|
+
let __React = null
|
|
25
|
+
try { __React = require('react') } catch {}
|
|
26
|
+
if (!__React || typeof __React.createElement !== 'function') {
|
|
27
|
+
__React = {
|
|
28
|
+
createElement(type, props, ...kids) {
|
|
29
|
+
return { type, props: props || {}, kids: kids.flat(9).filter((k) => k !== null && k !== undefined && k !== false && k !== true) }
|
|
30
|
+
},
|
|
31
|
+
useState(init) { const v = [typeof init === 'function' ? init() : init]; return [v[0], (x) => { v[0] = typeof x === 'function' ? x(v[0]) : x }] },
|
|
32
|
+
useEffect() {}, useMemo(fn) { return fn() }, useRef(v = null) { return { current: v } },
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const { createElement: h, useState, useEffect, useMemo, useRef } = __React
|
|
36
|
+
|
|
37
|
+
// react-dom is a loader platform module (portals + createRoot). Under plain
|
|
38
|
+
// Node (contract tests) it is absent; every use site guards on it and
|
|
39
|
+
// degrades (menu renders inline-less null, settings section skips mount).
|
|
40
|
+
let RDP = null
|
|
41
|
+
try { RDP = require('react-dom') } catch {}
|
|
42
|
+
|
|
43
|
+
const CLIENT_NAME = 'kczx-user-management'
|
|
44
|
+
const NS = 'user-management'
|
|
45
|
+
|
|
46
|
+
// ── locale ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const ZH = {
|
|
49
|
+
title: '用户管理',
|
|
50
|
+
loading: '加载中…',
|
|
51
|
+
notLoggedIn: '当前浏览器未登录(会话可能已过期),刷新页面重新登录。',
|
|
52
|
+
reload: '刷新',
|
|
53
|
+
tabUsers: '用户',
|
|
54
|
+
tabLoginLog: '登录记录',
|
|
55
|
+
tabAccessLog: '访问记录',
|
|
56
|
+
colUser: '用户名',
|
|
57
|
+
colRole: '角色',
|
|
58
|
+
colCreatedAt: '创建时间',
|
|
59
|
+
colLastLogin: '最后登录',
|
|
60
|
+
colTime: '时间',
|
|
61
|
+
colType: '类型',
|
|
62
|
+
colIp: 'IP',
|
|
63
|
+
colDetail: '详情',
|
|
64
|
+
colMethod: '方法',
|
|
65
|
+
colPath: '路径',
|
|
66
|
+
colStatus: '状态',
|
|
67
|
+
tabAuditLog: '操作日志',
|
|
68
|
+
filterPath: '按路径过滤…',
|
|
69
|
+
statusAll: '全部状态',
|
|
70
|
+
statusOk: '成功 (2xx)',
|
|
71
|
+
statusErr: '失败 (4xx/5xx)',
|
|
72
|
+
typeWsConn: 'WS 连接',
|
|
73
|
+
roleAdmin: '管理员',
|
|
74
|
+
roleUser: '普通用户',
|
|
75
|
+
you: '(我)',
|
|
76
|
+
actionResetPwd: '重置密码',
|
|
77
|
+
actionSetAdmin: '设为管理员',
|
|
78
|
+
actionSetUser: '降为普通用户',
|
|
79
|
+
actionDelete: '删除',
|
|
80
|
+
resetPwdConfirm: '重置 {name} 的密码?该用户的所有登录会话将被强制登出。',
|
|
81
|
+
deleteConfirm: '删除用户 {name}?此操作不可恢复。',
|
|
82
|
+
roleConfirm: '将 {name} 的角色变更为 {role}?该用户将被强制重新登录。',
|
|
83
|
+
tempPwdTitle: '临时密码',
|
|
84
|
+
tempPwdHint: '已为 {name} 重置密码,请立即保存,仅显示这一次:',
|
|
85
|
+
copy: '复制',
|
|
86
|
+
copied: '已复制',
|
|
87
|
+
filterUser: '按用户名过滤…',
|
|
88
|
+
filterAll: '全部类型',
|
|
89
|
+
myInfo: '我的信息',
|
|
90
|
+
changePwd: '修改密码',
|
|
91
|
+
oldPwd: '当前密码',
|
|
92
|
+
newPwd: '新密码(至少 6 位)',
|
|
93
|
+
confirmPwd: '确认新密码',
|
|
94
|
+
save: '保存',
|
|
95
|
+
pwdChanged: '密码已修改,其他会话已登出',
|
|
96
|
+
pwdMismatch: '两次输入的新密码不一致',
|
|
97
|
+
empty: '暂无记录',
|
|
98
|
+
opOk: '操作成功',
|
|
99
|
+
failed: '操作失败:',
|
|
100
|
+
clearLog: '清空',
|
|
101
|
+
clearLogConfirm: '确定清空全部操作日志?此操作不可恢复。',
|
|
102
|
+
delEntryConfirm: '删除这条操作记录?',
|
|
103
|
+
auditCleared: '操作日志已清空',
|
|
104
|
+
entryDeleted: '记录已删除',
|
|
105
|
+
actionLogout: '退出登录',
|
|
106
|
+
createUser: '新增用户',
|
|
107
|
+
createUserTitle: '新增用户',
|
|
108
|
+
colPassword: '密码(至少 6 位)',
|
|
109
|
+
colRolePick: '角色',
|
|
110
|
+
submitCreate: '创建',
|
|
111
|
+
userCreated: '用户已创建',
|
|
112
|
+
actionDisable: '禁用',
|
|
113
|
+
actionEnable: '启用',
|
|
114
|
+
badgeDisabled: '已禁用',
|
|
115
|
+
disableConfirm: '禁用 {name}?该用户的登录会话将被立即踢出,且无法再登录。',
|
|
116
|
+
enableConfirm: '启用 {name}?该用户将可以重新登录。',
|
|
117
|
+
tabBans: 'IP 封禁',
|
|
118
|
+
banIpPlaceholder: '输入 IP 地址…',
|
|
119
|
+
banNotePlaceholder: '备注(可选)',
|
|
120
|
+
banAction: '封禁',
|
|
121
|
+
unbanAction: '解封',
|
|
122
|
+
colNote: '备注',
|
|
123
|
+
colBannedBy: '封禁者',
|
|
124
|
+
banConfirm: '封禁 {ip}?该地址将无法访问任何页面与接口。',
|
|
125
|
+
unbanConfirm: '解封 {ip}?',
|
|
126
|
+
banned: 'IP 已封禁',
|
|
127
|
+
unbanned: 'IP 已解封',
|
|
128
|
+
selfIp: '(当前)',
|
|
129
|
+
bannedShort: '已封',
|
|
130
|
+
certTitle: 'HTTPS 证书',
|
|
131
|
+
certHint: '把证书导入本机信任链后,浏览器不再弹出证书警告。下载前请核对指纹。',
|
|
132
|
+
certDownloadPem: '下载 PEM (.crt)',
|
|
133
|
+
certDownloadDer: '下载 DER (.cer)',
|
|
134
|
+
certFingerprint: '指纹 (SHA-256)',
|
|
135
|
+
certValid: '有效期',
|
|
136
|
+
certHosts: '覆盖名称',
|
|
137
|
+
certInstall: '导入信任链(选择你的系统)',
|
|
138
|
+
certUnavailable: 'HTTPS 网关未启用,证书信息不可用。',
|
|
139
|
+
certCopied: '已复制',
|
|
140
|
+
osMac: 'macOS',
|
|
141
|
+
osWin: 'Windows',
|
|
142
|
+
osLinux: 'Linux',
|
|
143
|
+
typeUserCreated: '创建用户',
|
|
144
|
+
typeUserDisabled: '禁用用户',
|
|
145
|
+
typeUserEnabled: '启用用户',
|
|
146
|
+
typeBanIp: '封禁 IP',
|
|
147
|
+
typeUnbanIp: '解封 IP',
|
|
148
|
+
typeLogin: '登录',
|
|
149
|
+
typeLoginFailed: '登录失败',
|
|
150
|
+
typeLogout: '登出',
|
|
151
|
+
typePasswordChange: '修改密码',
|
|
152
|
+
typeRegister: '注册',
|
|
153
|
+
typeResetPassword: '重置密码',
|
|
154
|
+
typeRoleChange: '角色变更',
|
|
155
|
+
typeDeleteUser: '删除用户',
|
|
156
|
+
typeAccess: '页面访问',
|
|
157
|
+
typeTotpEnabled: '开启两步验证',
|
|
158
|
+
typeTotpDisabled: '关闭两步验证',
|
|
159
|
+
typeTotpReset: '重置两步验证',
|
|
160
|
+
totpTitle: '两步验证',
|
|
161
|
+
totpStatusOn: '已开启',
|
|
162
|
+
totpStatusOff: '未开启',
|
|
163
|
+
totpHint: '开启后,登录除密码外还需输入验证器 App 生成的 6 位动态码。',
|
|
164
|
+
totpEnableAction: '开启两步验证',
|
|
165
|
+
totpDisableAction: '关闭两步验证',
|
|
166
|
+
totpScanHint: '用验证器 App(Google Authenticator、1Password、微软验证器等)扫码,或手动输入下方密钥:',
|
|
167
|
+
totpCodeLabel: '输入 App 显示的 6 位动态码完成绑定',
|
|
168
|
+
totpCodePlaceholder: '6 位动态码',
|
|
169
|
+
totpActivateAction: '验证并开启',
|
|
170
|
+
totpEnabledToast: '两步验证已开启',
|
|
171
|
+
totpDisabledToast: '两步验证已关闭',
|
|
172
|
+
totpDisablePwdLabel: '输入登录密码确认关闭',
|
|
173
|
+
totpDisableConfirmTitle: '关闭两步验证',
|
|
174
|
+
totpResetAction: '重置两步验证',
|
|
175
|
+
totpResetConfirm: '重置 {name} 的两步验证?该用户下次登录将只需密码,并可重新绑定。',
|
|
176
|
+
totpResetDone: '两步验证已重置',
|
|
177
|
+
totpBadge: '两步',
|
|
178
|
+
userCount: '{n} 个用户',
|
|
179
|
+
directAccess: '本页是直连 dsh 端口打开的,用户管理只在登录网关入口提供服务。请改从网关地址访问(默认 http://<主机>:8080)后重试。',
|
|
180
|
+
colLimits: '权限 / 配额',
|
|
181
|
+
usageOver: '已超额',
|
|
182
|
+
usageToday: '今日用量:{summary}',
|
|
183
|
+
usageMinutes: '{used}/{limit} 分钟',
|
|
184
|
+
usageMinutesFree: '{used} 分钟',
|
|
185
|
+
usageTokens: '{used}/{limit} tok/h',
|
|
186
|
+
usageTokensFree: '{used} tok/h',
|
|
187
|
+
actionPermissions: '权限/配额',
|
|
188
|
+
permissionsTitle: '权限与配额',
|
|
189
|
+
permFolders: '工作区白名单',
|
|
190
|
+
permFoldersHint: '留空 = 允许访问所有工作区;填写后只允许这些目录下的工作区。',
|
|
191
|
+
permFoldersAll: '未限制(允许全部工作区)',
|
|
192
|
+
permFolderPlaceholder: '例如 /srv/work 或 D:\\work',
|
|
193
|
+
add: '添加',
|
|
194
|
+
remove: '移除',
|
|
195
|
+
permTokenLimit: '每小时 token 上限',
|
|
196
|
+
permMinuteLimit: '每日时长上限(分钟)',
|
|
197
|
+
permUnlimited: '留空 = 不限',
|
|
198
|
+
permSandbox: '沙盒档位下限',
|
|
199
|
+
permSandboxNone: '不限制(用户自选)',
|
|
200
|
+
permSandboxReadOnly: '只读',
|
|
201
|
+
permSandboxWorkspace: '可写工作区',
|
|
202
|
+
permSandboxFull: '完全访问',
|
|
203
|
+
permAllowUpload: '允许上传文件',
|
|
204
|
+
permAllowGit: '允许 git 下载',
|
|
205
|
+
permBanned: '封禁此账号(立即踢出会话并拒绝登录)',
|
|
206
|
+
permNote: '备注',
|
|
207
|
+
permUnrestricted: '未限制',
|
|
208
|
+
permBannedShort: '已封禁',
|
|
209
|
+
permFoldersShort: '{n} 个目录',
|
|
210
|
+
permTokensShort: '≤{n} tok/h',
|
|
211
|
+
permMinutesShort: '≤{n} min/日',
|
|
212
|
+
permNoUploadShort: '禁上传',
|
|
213
|
+
permNoGitShort: '禁 git',
|
|
214
|
+
openPanel: '打开用户管理',
|
|
215
|
+
panelHint: '用户账号、登录/访问记录、操作日志、IP 封禁、HTTPS 证书与两步验证,都在独立页面中管理。',
|
|
216
|
+
panelBack: '返回设置',
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const EN = {
|
|
220
|
+
title: 'User Management',
|
|
221
|
+
loading: 'Loading…',
|
|
222
|
+
notLoggedIn: 'Not signed in (session may have expired) — refresh the page to sign in again.',
|
|
223
|
+
reload: 'Refresh',
|
|
224
|
+
tabUsers: 'Users',
|
|
225
|
+
tabLoginLog: 'Login Records',
|
|
226
|
+
tabAccessLog: 'Access Records',
|
|
227
|
+
colUser: 'Username',
|
|
228
|
+
colRole: 'Role',
|
|
229
|
+
colCreatedAt: 'Created',
|
|
230
|
+
colLastLogin: 'Last Login',
|
|
231
|
+
colTime: 'Time',
|
|
232
|
+
colType: 'Type',
|
|
233
|
+
colIp: 'IP',
|
|
234
|
+
colDetail: 'Detail',
|
|
235
|
+
colMethod: 'Method',
|
|
236
|
+
colPath: 'Path',
|
|
237
|
+
colStatus: 'Status',
|
|
238
|
+
tabAuditLog: 'Operation Log',
|
|
239
|
+
filterPath: 'Filter by path…',
|
|
240
|
+
statusAll: 'All statuses',
|
|
241
|
+
statusOk: 'Success (2xx)',
|
|
242
|
+
statusErr: 'Failed (4xx/5xx)',
|
|
243
|
+
typeWsConn: 'WS connection',
|
|
244
|
+
roleAdmin: 'Admin',
|
|
245
|
+
roleUser: 'User',
|
|
246
|
+
you: ' (me)',
|
|
247
|
+
actionResetPwd: 'Reset Password',
|
|
248
|
+
actionSetAdmin: 'Make Admin',
|
|
249
|
+
actionSetUser: 'Demote to User',
|
|
250
|
+
actionDelete: 'Delete',
|
|
251
|
+
resetPwdConfirm: "Reset {name}'s password? All their sessions will be signed out.",
|
|
252
|
+
deleteConfirm: 'Delete user {name}? This cannot be undone.',
|
|
253
|
+
roleConfirm: "Change {name}'s role to {role}? They will be signed out.",
|
|
254
|
+
tempPwdTitle: 'Temporary Password',
|
|
255
|
+
tempPwdHint: "Password reset for {name}. Save it now — shown only once:",
|
|
256
|
+
copy: 'Copy',
|
|
257
|
+
copied: 'Copied',
|
|
258
|
+
filterUser: 'Filter by username…',
|
|
259
|
+
filterAll: 'All types',
|
|
260
|
+
myInfo: 'My Profile',
|
|
261
|
+
changePwd: 'Change Password',
|
|
262
|
+
oldPwd: 'Current password',
|
|
263
|
+
newPwd: 'New password (min 6 chars)',
|
|
264
|
+
confirmPwd: 'Confirm new password',
|
|
265
|
+
save: 'Save',
|
|
266
|
+
pwdChanged: 'Password changed; other sessions signed out',
|
|
267
|
+
pwdMismatch: 'The two new passwords do not match',
|
|
268
|
+
empty: 'No records',
|
|
269
|
+
opOk: 'Done',
|
|
270
|
+
failed: 'Operation failed: ',
|
|
271
|
+
clearLog: 'Clear',
|
|
272
|
+
clearLogConfirm: 'Clear the entire operation log? This cannot be undone.',
|
|
273
|
+
delEntryConfirm: 'Delete this audit entry?',
|
|
274
|
+
auditCleared: 'Operation log cleared',
|
|
275
|
+
entryDeleted: 'Entry deleted',
|
|
276
|
+
actionLogout: 'Sign out',
|
|
277
|
+
createUser: 'New User',
|
|
278
|
+
createUserTitle: 'New User',
|
|
279
|
+
colPassword: 'Password (min 6 chars)',
|
|
280
|
+
colRolePick: 'Role',
|
|
281
|
+
submitCreate: 'Create',
|
|
282
|
+
userCreated: 'User created',
|
|
283
|
+
actionDisable: 'Disable',
|
|
284
|
+
actionEnable: 'Enable',
|
|
285
|
+
badgeDisabled: 'Disabled',
|
|
286
|
+
disableConfirm: 'Disable {name}? Their sessions are signed out immediately and they can no longer sign in.',
|
|
287
|
+
enableConfirm: 'Enable {name}? They will be able to sign in again.',
|
|
288
|
+
tabBans: 'IP Bans',
|
|
289
|
+
banIpPlaceholder: 'Enter an IP address…',
|
|
290
|
+
banNotePlaceholder: 'Note (optional)',
|
|
291
|
+
banAction: 'Ban',
|
|
292
|
+
unbanAction: 'Unban',
|
|
293
|
+
colNote: 'Note',
|
|
294
|
+
colBannedBy: 'Banned by',
|
|
295
|
+
banConfirm: 'Ban {ip}? This address will be unable to reach any page or API.',
|
|
296
|
+
unbanConfirm: 'Unban {ip}?',
|
|
297
|
+
banned: 'IP banned',
|
|
298
|
+
unbanned: 'IP unbanned',
|
|
299
|
+
selfIp: ' (current)',
|
|
300
|
+
bannedShort: 'Banned',
|
|
301
|
+
certTitle: 'HTTPS Certificate',
|
|
302
|
+
certHint: 'Import this certificate into your trust store and browsers stop warning about the self-signed gateway. Verify the fingerprint before importing.',
|
|
303
|
+
certDownloadPem: 'Download PEM (.crt)',
|
|
304
|
+
certDownloadDer: 'Download DER (.cer)',
|
|
305
|
+
certFingerprint: 'Fingerprint (SHA-256)',
|
|
306
|
+
certValid: 'Valid until',
|
|
307
|
+
certHosts: 'Covered names',
|
|
308
|
+
certInstall: 'Import into the trust store (pick your OS)',
|
|
309
|
+
certUnavailable: 'HTTPS gateway not enabled — certificate info unavailable.',
|
|
310
|
+
certCopied: 'Copied',
|
|
311
|
+
osMac: 'macOS',
|
|
312
|
+
osWin: 'Windows',
|
|
313
|
+
osLinux: 'Linux',
|
|
314
|
+
typeUserCreated: 'User created',
|
|
315
|
+
typeUserDisabled: 'User disabled',
|
|
316
|
+
typeUserEnabled: 'User enabled',
|
|
317
|
+
typeBanIp: 'IP banned',
|
|
318
|
+
typeUnbanIp: 'IP unbanned',
|
|
319
|
+
typeLogin: 'Login',
|
|
320
|
+
typeLoginFailed: 'Login failed',
|
|
321
|
+
typeLogout: 'Logout',
|
|
322
|
+
typePasswordChange: 'Password change',
|
|
323
|
+
typeRegister: 'Register',
|
|
324
|
+
typeResetPassword: 'Password reset',
|
|
325
|
+
typeRoleChange: 'Role change',
|
|
326
|
+
typeDeleteUser: 'User deleted',
|
|
327
|
+
typeAccess: 'Page access',
|
|
328
|
+
typeTotpEnabled: '2-step enabled',
|
|
329
|
+
typeTotpDisabled: '2-step disabled',
|
|
330
|
+
typeTotpReset: '2-step reset',
|
|
331
|
+
totpTitle: 'Two-Step Verification',
|
|
332
|
+
totpStatusOn: 'On',
|
|
333
|
+
totpStatusOff: 'Off',
|
|
334
|
+
totpHint: 'When enabled, signing in requires a 6-digit code from your authenticator app in addition to the password.',
|
|
335
|
+
totpEnableAction: 'Enable 2-Step',
|
|
336
|
+
totpDisableAction: 'Disable 2-Step',
|
|
337
|
+
totpScanHint: 'Scan with an authenticator app (Google Authenticator, 1Password, Microsoft Authenticator, …), or enter the key manually:',
|
|
338
|
+
totpCodeLabel: 'Enter the 6-digit code from the app to finish enrollment',
|
|
339
|
+
totpCodePlaceholder: '6-digit code',
|
|
340
|
+
totpActivateAction: 'Verify & Enable',
|
|
341
|
+
totpEnabledToast: 'Two-step verification enabled',
|
|
342
|
+
totpDisabledToast: 'Two-step verification disabled',
|
|
343
|
+
totpDisablePwdLabel: 'Enter your login password to confirm',
|
|
344
|
+
totpDisableConfirmTitle: 'Disable Two-Step Verification',
|
|
345
|
+
totpResetAction: 'Reset 2-Step',
|
|
346
|
+
totpResetConfirm: "Reset {name}'s two-step verification? Their next sign-in needs the password only, and they can re-enroll.",
|
|
347
|
+
totpResetDone: 'Two-step verification reset',
|
|
348
|
+
totpBadge: '2FA',
|
|
349
|
+
userCount: '{n} user(s)',
|
|
350
|
+
directAccess: 'This page was opened on the dsh port directly. User management is only served behind the login gateway — open the gateway address instead (default http://<host>:8080) and retry.',
|
|
351
|
+
colLimits: 'Permissions / Quota',
|
|
352
|
+
usageOver: 'Over quota',
|
|
353
|
+
usageToday: 'Today: {summary}',
|
|
354
|
+
usageMinutes: '{used}/{limit} min',
|
|
355
|
+
usageMinutesFree: '{used} min',
|
|
356
|
+
usageTokens: '{used}/{limit} tok/h',
|
|
357
|
+
usageTokensFree: '{used} tok/h',
|
|
358
|
+
actionPermissions: 'Limits',
|
|
359
|
+
permissionsTitle: 'Permissions & Quota',
|
|
360
|
+
permFolders: 'Workspace allow-list',
|
|
361
|
+
permFoldersHint: 'Empty = every workspace is allowed; once filled, only workspaces under these folders are.',
|
|
362
|
+
permFoldersAll: 'Unrestricted (all workspaces allowed)',
|
|
363
|
+
permFolderPlaceholder: 'e.g. /srv/work or D:\\work',
|
|
364
|
+
add: 'Add',
|
|
365
|
+
remove: 'Remove',
|
|
366
|
+
permTokenLimit: 'Tokens per hour',
|
|
367
|
+
permMinuteLimit: 'Minutes per day',
|
|
368
|
+
permUnlimited: 'Empty = no cap',
|
|
369
|
+
permSandbox: 'Sandbox floor',
|
|
370
|
+
permSandboxNone: 'Unrestricted (user chooses)',
|
|
371
|
+
permSandboxReadOnly: 'Read-only',
|
|
372
|
+
permSandboxWorkspace: 'Workspace write',
|
|
373
|
+
permSandboxFull: 'Full access',
|
|
374
|
+
permAllowUpload: 'Allow file uploads',
|
|
375
|
+
permAllowGit: 'Allow git downloads',
|
|
376
|
+
permBanned: 'Ban this account (drops sessions and refuses sign-in)',
|
|
377
|
+
permNote: 'Note',
|
|
378
|
+
permUnrestricted: 'Unrestricted',
|
|
379
|
+
permBannedShort: 'Banned',
|
|
380
|
+
permFoldersShort: '{n} folder(s)',
|
|
381
|
+
permTokensShort: '≤{n} tok/h',
|
|
382
|
+
permMinutesShort: '≤{n} min/day',
|
|
383
|
+
permNoUploadShort: 'no upload',
|
|
384
|
+
permNoGitShort: 'no git',
|
|
385
|
+
openPanel: 'Open User Management',
|
|
386
|
+
panelHint: 'Accounts, login and access records, the operation log, IP bans, the HTTPS certificate and two-step verification — all in a dedicated page.',
|
|
387
|
+
panelBack: 'Back to settings',
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Ledger event type → locale key (also the login-log type filter). */
|
|
391
|
+
const TYPE_LABELS = {
|
|
392
|
+
login: 'typeLogin',
|
|
393
|
+
login_failed: 'typeLoginFailed',
|
|
394
|
+
logout: 'typeLogout',
|
|
395
|
+
password_change: 'typePasswordChange',
|
|
396
|
+
register: 'typeRegister',
|
|
397
|
+
reset_password: 'typeResetPassword',
|
|
398
|
+
role_change: 'typeRoleChange',
|
|
399
|
+
delete_user: 'typeDeleteUser',
|
|
400
|
+
access: 'typeAccess',
|
|
401
|
+
user_created: 'typeUserCreated',
|
|
402
|
+
user_disabled: 'typeUserDisabled',
|
|
403
|
+
user_enabled: 'typeUserEnabled',
|
|
404
|
+
ban_ip: 'typeBanIp',
|
|
405
|
+
unban_ip: 'typeUnbanIp',
|
|
406
|
+
totp_enabled: 'typeTotpEnabled',
|
|
407
|
+
totp_disabled: 'typeTotpDisabled',
|
|
408
|
+
totp_reset: 'typeTotpReset',
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── helpers ───────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
function api(path, options = {}) {
|
|
414
|
+
const init = { method: options.method || 'GET', headers: {} }
|
|
415
|
+
if (options.body !== undefined) {
|
|
416
|
+
init.headers['content-type'] = 'application/json'
|
|
417
|
+
init.body = JSON.stringify(options.body)
|
|
418
|
+
}
|
|
419
|
+
return fetch('/user-management/api' + path, init).then(async (res) => {
|
|
420
|
+
const data = await res.json().catch(() => ({}))
|
|
421
|
+
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`)
|
|
422
|
+
return data
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatTime(ts) {
|
|
427
|
+
if (!ts) return '-'
|
|
428
|
+
try { return new Date(ts).toLocaleString() } catch { return '-' }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function interpolate(template, vars) {
|
|
432
|
+
let out = template
|
|
433
|
+
for (const [key, value] of Object.entries(vars || {})) out = out.split('{' + key + '}').join(String(value))
|
|
434
|
+
return out
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Client-side tab filters for the admin login-records ledger. */
|
|
438
|
+
function filterActivity(entries, { username, type } = {}) {
|
|
439
|
+
return (entries || []).filter((entry) => {
|
|
440
|
+
if (username && entry.username !== username) return false
|
|
441
|
+
if (type && entry.type !== type) return false
|
|
442
|
+
return true
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Client-side filters for the operation audit ledger. */
|
|
447
|
+
function filterAudit(entries, { username, method, path, statusClass } = {}) {
|
|
448
|
+
return (entries || []).filter((entry) => {
|
|
449
|
+
if (username && entry.username !== username) return false
|
|
450
|
+
if (method && entry.method !== method) return false
|
|
451
|
+
if (path && !String(entry.path || '').includes(path)) return false
|
|
452
|
+
if (statusClass && String(entry.status || '').charAt(0) !== statusClass) return false
|
|
453
|
+
return true
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Loopback / self addresses must never be bannable (defense in depth —
|
|
458
|
+
* the server refuses them too). */
|
|
459
|
+
const LOOPBACK_IP_RE = /^127(?:\.\d{1,3}){3}$|^::1$|^\[::1\]$/
|
|
460
|
+
|
|
461
|
+
function canBanIp(ip, selfIp) {
|
|
462
|
+
if (!ip || ip === '-') return false
|
|
463
|
+
if (LOOPBACK_IP_RE.test(ip)) return false
|
|
464
|
+
if (selfIp && ip === selfIp) return false
|
|
465
|
+
return true
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Inline ban button for IP cells in the log tables. Hidden for loopback /
|
|
469
|
+
* own-IP entries; disables itself after a successful ban. */
|
|
470
|
+
function BanIpButton({ ip, selfIp, __t: t, flash }) {
|
|
471
|
+
const [busy, setBusy] = useState(false)
|
|
472
|
+
const [done, setDone] = useState(false)
|
|
473
|
+
if (!canBanIp(ip, selfIp)) return null
|
|
474
|
+
return h('button', {
|
|
475
|
+
className: 'um-btn um-btn-danger',
|
|
476
|
+
style: { marginLeft: 6, padding: '0 7px', fontSize: 11, lineHeight: '18px' },
|
|
477
|
+
disabled: busy || done,
|
|
478
|
+
title: interpolate(t('banConfirm'), { ip }),
|
|
479
|
+
onClick: () => {
|
|
480
|
+
if (!window.confirm(interpolate(t('banConfirm'), { ip }))) return
|
|
481
|
+
setBusy(true)
|
|
482
|
+
api('/bans', { method: 'POST', body: { ip, note: '' } })
|
|
483
|
+
.then(() => { flash(t('banned')); setDone(true) })
|
|
484
|
+
.catch((e) => flash(t('failed') + e.message))
|
|
485
|
+
.finally(() => setBusy(false))
|
|
486
|
+
},
|
|
487
|
+
}, done ? t('bannedShort') : t('banAction'))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── styles ────────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
const STYLE = `
|
|
493
|
+
.um-wrap { font-size: 13px; color: var(--dsw-alias-label-primary); display: flex; flex-direction: column; gap: 14px; }
|
|
494
|
+
.um-avatar { border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: rgba(255,255,255,.95); font-weight: 600; flex: none; user-select: none; letter-spacing: 0; }
|
|
495
|
+
.um-brand-name { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; max-width: 150px; }
|
|
496
|
+
.um-logout { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; padding: 6px 10px; border-radius: 7px; cursor: pointer; flex: none; }
|
|
497
|
+
.um-logout:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-error-primary); }
|
|
498
|
+
.um-user-menu { min-width: 216px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.18); padding: 6px; }
|
|
499
|
+
.um-user-menu-head { display: flex; align-items: center; gap: 10px; padding: 8px 10px; }
|
|
500
|
+
.um-user-menu-name { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
501
|
+
.um-user-menu-sep { height: 1px; background: var(--dsw-alias-border-l1); margin: 4px 6px; }
|
|
502
|
+
.um-user-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: transparent; color: var(--dsw-alias-label-primary); font-size: 12.5px; padding: 8px 10px; border-radius: 7px; cursor: pointer; text-align: left; }
|
|
503
|
+
.um-user-menu-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
504
|
+
.um-user-menu-logout { color: var(--dsw-alias-state-error-primary); }
|
|
505
|
+
.um-user-menu-logout:hover { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }
|
|
506
|
+
.um-card { background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; padding: 14px 16px; }
|
|
507
|
+
.um-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
508
|
+
.um-title { font-size: 14px; font-weight: 600; margin: 0; }
|
|
509
|
+
.um-muted { color: var(--dsw-alias-label-secondary); }
|
|
510
|
+
.um-btn { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); border-radius: 7px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
|
|
511
|
+
.um-btn:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
512
|
+
.um-btn:disabled { opacity: .45; cursor: default; }
|
|
513
|
+
.um-btn-primary { background: var(--dsw-alias-state-business-primary); border-color: transparent; color: var(--dsw-alias-label-primary-inverted, #fff); }
|
|
514
|
+
.um-btn-danger { color: var(--dsw-alias-state-error-primary); }
|
|
515
|
+
.um-tabs { display: flex; gap: 4px; background: var(--dsw-alias-bg-layer-2); border-radius: 8px; padding: 3px; width: max-content; }
|
|
516
|
+
.um-tab { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer; }
|
|
517
|
+
.um-tab.active { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); }
|
|
518
|
+
.um-table-wrap { border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; overflow: auto; background: var(--dsw-alias-bg-layer-1); box-shadow: 0 1px 2px rgba(0,0,0,.05); }
|
|
519
|
+
.um-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
|
|
520
|
+
.um-table th { text-align: left; font-size: 11px; font-weight: 600; letter-spacing: .04em; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-2); padding: 8px 12px; white-space: nowrap; border-bottom: 1px solid var(--dsw-alias-border-l2); position: sticky; top: 0; z-index: 1; }
|
|
521
|
+
.um-table td { padding: 8px 12px; border-bottom: 1px solid var(--dsw-alias-border-l2); vertical-align: middle; color: var(--dsw-alias-label-primary); }
|
|
522
|
+
.um-table tbody tr:last-child td { border-bottom: 0; }
|
|
523
|
+
.um-table tbody tr:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
524
|
+
.um-empty { padding: 26px 0; text-align: center; color: var(--dsw-alias-label-tertiary); font-size: 12.5px; }
|
|
525
|
+
.um-badge { display: inline-block; font-size: 11px; line-height: 1.5; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); }
|
|
526
|
+
.um-badge-admin { color: var(--dsw-alias-state-business-primary); border-color: var(--dsw-alias-state-business-primary); background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); }
|
|
527
|
+
.um-badge-ok { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); }
|
|
528
|
+
.um-badge-err { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
|
|
529
|
+
.um-input { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); border-radius: 7px; padding: 5px 9px; font-size: 12px; outline: none; }
|
|
530
|
+
.um-input:focus { border-color: var(--dsw-alias-state-business-primary); }
|
|
531
|
+
.um-form { display: flex; flex-direction: column; gap: 8px; max-width: 340px; }
|
|
532
|
+
.um-form label { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
533
|
+
.um-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
534
|
+
.um-toast { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 8px 16px; font-size: 12px; z-index: 1080; box-shadow: 0 4px 16px rgba(0,0,0,.18); }
|
|
535
|
+
.um-dlg-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1060; }
|
|
536
|
+
.um-dlg { background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; padding: 18px 20px; width: 420px; max-width: 92vw; box-shadow: 0 10px 40px rgba(0,0,0,.25); }
|
|
537
|
+
.um-dlg h3 { margin: 0 0 10px; font-size: 14px; }
|
|
538
|
+
.um-temp-pwd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; background: var(--dsw-alias-bg-layer-2); border: 1px dashed var(--dsw-alias-border-l2); border-radius: 8px; padding: 10px 12px; margin: 10px 0; text-align: center; user-select: all; }
|
|
539
|
+
.um-dlg-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
|
540
|
+
.um-kv { display: grid; grid-template-columns: 96px 1fr; gap: 6px 12px; font-size: 13px; }
|
|
541
|
+
.um-kv dt { color: var(--dsw-alias-label-secondary); }
|
|
542
|
+
.um-kv dd { margin: 0; }
|
|
543
|
+
/* QR ground must stay light for scanner contrast — functional, not themed. */
|
|
544
|
+
.um-qr { background: white; padding: 10px; border-radius: 10px; width: fit-content; border: 1px solid var(--dsw-alias-border-l1); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
|
545
|
+
.um-qr svg { display: block; }
|
|
546
|
+
/* Settings-section entry: the console is a page of its own, so the slot only
|
|
547
|
+
offers the door. */
|
|
548
|
+
.um-entry { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
|
549
|
+
.um-entry-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
550
|
+
/* Full-page surface: portaled to <body> and pinned above the host settings
|
|
551
|
+
overlay, which is itself a fixed inset:0 layer at z-index 1000
|
|
552
|
+
(ui-settings-general SettingsRoot.module.css). Anything below 1000 lands
|
|
553
|
+
behind the settings panel and is invisible — hence 1050 here. The plugin's
|
|
554
|
+
own dialogs and toasts then sit above this page (1060 / 1080). */
|
|
555
|
+
.um-page-backdrop { position: fixed; inset: 0; z-index: 1050; display: flex; flex-direction: column; background: var(--dsw-alias-bg-layer-1); }
|
|
556
|
+
.um-page-head { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 22px; border-bottom: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); }
|
|
557
|
+
.um-page-title { font-size: 15px; font-weight: 600; margin: 0; }
|
|
558
|
+
.um-page-body { flex: 1; overflow: auto; padding: 16px 22px 48px; }
|
|
559
|
+
.um-page-inner { width: 100%; max-width: 1120px; margin: 0 auto; }
|
|
560
|
+
`
|
|
561
|
+
|
|
562
|
+
function ensureStyles() {
|
|
563
|
+
if (typeof document === 'undefined' || document.getElementById('um-styles')) return
|
|
564
|
+
// Must be a real <style> element — CSS text inside a <div> never applies.
|
|
565
|
+
const el = document.createElement('style')
|
|
566
|
+
el.id = 'um-styles'
|
|
567
|
+
el.textContent = STYLE
|
|
568
|
+
document.head.appendChild(el)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Self-drawn dialog: the host Modal portals to <body> with a z-index below
|
|
572
|
+
* full pages, so settings-section surfaces must draw their own. */
|
|
573
|
+
function UmDialog({ title, onClose, children, footer }) {
|
|
574
|
+
return h('div', { className: 'um-dlg-backdrop', onClick: onClose },
|
|
575
|
+
h('div', { className: 'um-dlg', onClick: (e) => e.stopPropagation() },
|
|
576
|
+
title && h('h3', null, title),
|
|
577
|
+
children,
|
|
578
|
+
footer && h('div', { className: 'um-dlg-foot' }, footer)))
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── sidebar brand (avatar + username, top-left) ───────────────────────────
|
|
582
|
+
|
|
583
|
+
/** Module-level session cache: the brand row and the settings section share
|
|
584
|
+
* one /session fetch per page load. undefined = loading, null = signed out.
|
|
585
|
+
* `observer` mirrors the server flag: true means dsh-passwords owns the login
|
|
586
|
+
* and this layer is only the ledger observer, so everything that belongs to a
|
|
587
|
+
* login door of its own (accounts, bans, certificates, 2FA, sign-out) is
|
|
588
|
+
* hidden. Read after the fetch resolves — every consumer re-renders then. */
|
|
589
|
+
const sessionCache = { me: undefined, observer: false, direct: false }
|
|
590
|
+
function loadMeCached() {
|
|
591
|
+
if (sessionCache.me !== undefined) return Promise.resolve(sessionCache.me)
|
|
592
|
+
return api('/session').then((d) => {
|
|
593
|
+
sessionCache.me = d.user
|
|
594
|
+
sessionCache.observer = d.observer === true
|
|
595
|
+
return d.user
|
|
596
|
+
})
|
|
597
|
+
.catch((error) => {
|
|
598
|
+
sessionCache.me = null
|
|
599
|
+
// 404 means this page sits on the dsh port itself: the ledger API only
|
|
600
|
+
// exists behind the gateway listener, so "sign in again" would be
|
|
601
|
+
// useless advice — the visitor has to switch to the gateway address.
|
|
602
|
+
sessionCache.direct = String(error && error.message).includes('404')
|
|
603
|
+
return null
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function avatarHue(username) {
|
|
608
|
+
let hash = 0
|
|
609
|
+
for (const ch of String(username || '?')) hash = (hash * 31 + ch.codePointAt(0)) % 360
|
|
610
|
+
return hash
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Initial-letter avatar: deterministic hue per username, theme-safe. */
|
|
614
|
+
function UserAvatar({ username, size = 22 }) {
|
|
615
|
+
const label = String(username || '?')
|
|
616
|
+
return h('div', {
|
|
617
|
+
className: 'um-avatar',
|
|
618
|
+
title: label,
|
|
619
|
+
style: {
|
|
620
|
+
width: size,
|
|
621
|
+
height: size,
|
|
622
|
+
fontSize: Math.max(10, Math.round(size * 0.44)),
|
|
623
|
+
background: `hsl(${avatarHue(label)}, 42%, 46%)`,
|
|
624
|
+
},
|
|
625
|
+
}, label.charAt(0).toUpperCase())
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Sidebar brand mark — host renders it in the expanded brand row AND the
|
|
629
|
+
* collapsed rail (owner supplies the square `size`), so fold states adapt
|
|
630
|
+
* without us tracking the shell. Clicking it (or the name) opens the
|
|
631
|
+
* user menu; sign-out sits at the bottom in red. */
|
|
632
|
+
function BrandMark({ size, className }) {
|
|
633
|
+
useEffect(ensureStyles, [])
|
|
634
|
+
const [me, setMe] = useState(sessionCache.me)
|
|
635
|
+
useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
|
|
636
|
+
const { toggle, menu, dialog } = useUserMenu(me)
|
|
637
|
+
if (!me) {
|
|
638
|
+
return h('div', { className, style: { width: size, height: size, borderRadius: '50%', background: 'var(--dsw-alias-bg-layer-3)' } })
|
|
639
|
+
}
|
|
640
|
+
return h('div', {
|
|
641
|
+
className,
|
|
642
|
+
style: { cursor: 'pointer', display: 'inline-flex', borderRadius: '50%' },
|
|
643
|
+
title: me.username,
|
|
644
|
+
'aria-haspopup': 'menu',
|
|
645
|
+
onClick: toggle,
|
|
646
|
+
},
|
|
647
|
+
h(UserAvatar, { username: me.username, size: size || 24 }),
|
|
648
|
+
menu,
|
|
649
|
+
dialog)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Sidebar brand name — host renders it beside the expanded mark only. */
|
|
653
|
+
function BrandName() {
|
|
654
|
+
useEffect(ensureStyles, [])
|
|
655
|
+
const [me, setMe] = useState(sessionCache.me)
|
|
656
|
+
useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
|
|
657
|
+
const { toggle, menu, dialog } = useUserMenu(me)
|
|
658
|
+
return h('span', {
|
|
659
|
+
className: 'um-brand-name',
|
|
660
|
+
style: { cursor: 'pointer' },
|
|
661
|
+
title: me ? me.username : '',
|
|
662
|
+
'aria-haspopup': 'menu',
|
|
663
|
+
onClick: toggle,
|
|
664
|
+
}, me ? me.username : '', menu, dialog)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── logout ────────────────────────────────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
/** Locale accessor for slot components living outside apply()'s closure —
|
|
670
|
+
* updated when apply() binds the real dictionary; zh until then. */
|
|
671
|
+
let localeT = (key) => ZH[key] || key
|
|
672
|
+
|
|
673
|
+
function doLogout() {
|
|
674
|
+
// clearing the session makes the gate bounce the next navigation to /login
|
|
675
|
+
api('/logout', { method: 'POST' }).catch(() => {}).finally(() => { location.href = '/login' })
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function PowerIcon({ size = 14 }) {
|
|
679
|
+
return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 2, 'stroke-linecap': 'round', 'aria-hidden': 'true' },
|
|
680
|
+
h('path', { d: 'M18.36 6.64a9 9 0 1 1-12.73 0' }),
|
|
681
|
+
h('line', { x1: 12, y1: 2, x2: 12, y2: 12 }))
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function KeyIcon({ size = 14 }) {
|
|
685
|
+
return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' },
|
|
686
|
+
h('path', { d: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }))
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function LogoutButton({ withLabel, __t: t }) {
|
|
690
|
+
useEffect(ensureStyles, [])
|
|
691
|
+
return h('button', {
|
|
692
|
+
className: 'um-logout',
|
|
693
|
+
title: t('actionLogout'),
|
|
694
|
+
'aria-label': t('actionLogout'),
|
|
695
|
+
onClick: doLogout,
|
|
696
|
+
}, h(PowerIcon), withLabel ? t('actionLogout') : null)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Self-service password change — the admin panel has no inline form
|
|
700
|
+
* (MyPanel's one is for plain users), so this dialog is the shared
|
|
701
|
+
* entry from the user menu. Portaled to <body> like the user menu. */
|
|
702
|
+
function ChangePasswordDialog({ onClose, __t: t }) {
|
|
703
|
+
const [oldPwd, setOldPwd] = useState('')
|
|
704
|
+
const [newPwd, setNewPwd] = useState('')
|
|
705
|
+
const [confirmPwd, setConfirmPwd] = useState('')
|
|
706
|
+
const [busy, setBusy] = useState(false)
|
|
707
|
+
const [err, setErr] = useState('')
|
|
708
|
+
const [done, setDone] = useState(false)
|
|
709
|
+
|
|
710
|
+
const submit = (e) => {
|
|
711
|
+
e.preventDefault()
|
|
712
|
+
if (newPwd !== confirmPwd) { setErr(t('pwdMismatch')); return }
|
|
713
|
+
setBusy(true)
|
|
714
|
+
setErr('')
|
|
715
|
+
api('/me/password', { method: 'POST', body: { oldPassword: oldPwd, newPassword: newPwd } })
|
|
716
|
+
.then(() => { setDone(true); setTimeout(onClose, 1200) })
|
|
717
|
+
.catch((e2) => setErr(String(e2 && e2.message)))
|
|
718
|
+
.finally(() => setBusy(false))
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const dialog = h(UmDialog, {
|
|
722
|
+
title: t('changePwd'), onClose,
|
|
723
|
+
footer: [
|
|
724
|
+
h('button', { className: 'um-btn', onClick: onClose }, '✕'),
|
|
725
|
+
h('button', { className: 'um-btn um-btn-primary', onClick: submit, disabled: busy || done }, t('save')),
|
|
726
|
+
],
|
|
727
|
+
},
|
|
728
|
+
done
|
|
729
|
+
? h('div', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 13, padding: '6px 0' } }, t('pwdChanged'))
|
|
730
|
+
: h('form', { className: 'um-form', onSubmit: submit, style: { maxWidth: 'none' } },
|
|
731
|
+
h('label', { htmlFor: 'um-cp-old' }, t('oldPwd')),
|
|
732
|
+
h('input', { id: 'um-cp-old', className: 'um-input', type: 'password', autoComplete: 'current-password', value: oldPwd, onChange: (e) => setOldPwd(e.target.value), required: true, autoFocus: true }),
|
|
733
|
+
h('label', { htmlFor: 'um-cp-new' }, t('newPwd')),
|
|
734
|
+
h('input', { id: 'um-cp-new', className: 'um-input', type: 'password', autoComplete: 'new-password', minLength: 6, value: newPwd, onChange: (e) => setNewPwd(e.target.value), required: true }),
|
|
735
|
+
h('label', { htmlFor: 'um-cp-conf' }, t('confirmPwd')),
|
|
736
|
+
h('input', { id: 'um-cp-conf', className: 'um-input', type: 'password', autoComplete: 'new-password', value: confirmPwd, onChange: (e) => setConfirmPwd(e.target.value), required: true }),
|
|
737
|
+
err && h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, err)))
|
|
738
|
+
return RDP && typeof RDP.createPortal === 'function' ? RDP.createPortal(dialog, document.body) : dialog
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** The user menu popped from the brand row: identity header, change
|
|
742
|
+
* password, separator, and the red sign-out as the last item. Portaled
|
|
743
|
+
* to <body> so sidebar overflow can't clip it. */
|
|
744
|
+
function UserMenu({ anchor, username, role, onClose, onChangePwd }) {
|
|
745
|
+
const ref = useRef(null)
|
|
746
|
+
const t = localeT
|
|
747
|
+
useEffect(() => {
|
|
748
|
+
const onDoc = (e) => {
|
|
749
|
+
if (ref.current && !ref.current.contains(e.target) && anchor && !anchor.contains(e.target)) onClose()
|
|
750
|
+
}
|
|
751
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
|
752
|
+
document.addEventListener('mousedown', onDoc)
|
|
753
|
+
document.addEventListener('keydown', onKey)
|
|
754
|
+
return () => {
|
|
755
|
+
document.removeEventListener('mousedown', onDoc)
|
|
756
|
+
document.removeEventListener('keydown', onKey)
|
|
757
|
+
}
|
|
758
|
+
}, [anchor, onClose])
|
|
759
|
+
const rect = anchor ? anchor.getBoundingClientRect() : { left: 8, bottom: 8, right: 8 }
|
|
760
|
+
const MENU_WIDTH = 216
|
|
761
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENU_WIDTH - 8))
|
|
762
|
+
const style = { position: 'fixed', left, top: rect.bottom + 6, width: MENU_WIDTH }
|
|
763
|
+
return RDP && typeof RDP.createPortal === 'function'
|
|
764
|
+
? RDP.createPortal(
|
|
765
|
+
h('div', { className: 'um-user-menu', ref, style, role: 'menu' },
|
|
766
|
+
h('div', { className: 'um-user-menu-head' },
|
|
767
|
+
h(UserAvatar, { username, size: 30 }),
|
|
768
|
+
h('div', { style: { minWidth: 0 } },
|
|
769
|
+
h('div', { className: 'um-user-menu-name', title: username }, username),
|
|
770
|
+
h('div', { style: { marginTop: 3 } }, h(RoleBadge, { role, __t: t })))),
|
|
771
|
+
// Observer mode: password and sign-out live at the door (dsh-passwords),
|
|
772
|
+
// not here — offering them would promise something this layer can't do.
|
|
773
|
+
sessionCache.observer ? null : h('div', { className: 'um-user-menu-sep' }),
|
|
774
|
+
sessionCache.observer ? null : h('button', {
|
|
775
|
+
className: 'um-user-menu-item',
|
|
776
|
+
role: 'menuitem',
|
|
777
|
+
onClick: () => { onClose(); onChangePwd() },
|
|
778
|
+
}, h(KeyIcon), t('changePwd')),
|
|
779
|
+
sessionCache.observer ? null : h('div', { className: 'um-user-menu-sep' }),
|
|
780
|
+
sessionCache.observer ? null : h('button', {
|
|
781
|
+
className: 'um-user-menu-item um-user-menu-logout',
|
|
782
|
+
role: 'menuitem',
|
|
783
|
+
onClick: () => { onClose(); doLogout() },
|
|
784
|
+
}, h(PowerIcon), t('actionLogout'))),
|
|
785
|
+
document.body)
|
|
786
|
+
: null
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Shared click-to-open behaviour for both brand seats. */
|
|
790
|
+
function useUserMenu(me) {
|
|
791
|
+
const [anchor, setAnchor] = useState(null)
|
|
792
|
+
const [pwdOpen, setPwdOpen] = useState(false)
|
|
793
|
+
const toggle = (e) => {
|
|
794
|
+
// The menu/dialog are React children of this element but portal their
|
|
795
|
+
// DOM to <body> — their clicks bubble through the React tree and would
|
|
796
|
+
// toggle the menu a second time. Only clicks that physically originate
|
|
797
|
+
// inside the brand element may toggle.
|
|
798
|
+
if (!e.currentTarget.contains(e.target)) return
|
|
799
|
+
e.stopPropagation()
|
|
800
|
+
// React nulls e.currentTarget after dispatch; the updater may run later,
|
|
801
|
+
// so capture the element first or the anchor silently stays null.
|
|
802
|
+
const target = e.currentTarget
|
|
803
|
+
setAnchor((current) => (current ? null : target))
|
|
804
|
+
}
|
|
805
|
+
const menu = (anchor && me)
|
|
806
|
+
? h(UserMenu, { anchor, username: me.username, role: me.role, onClose: () => setAnchor(null), onChangePwd: () => setPwdOpen(true) })
|
|
807
|
+
: null
|
|
808
|
+
const dialog = pwdOpen ? h(ChangePasswordDialog, { onClose: () => setPwdOpen(false), __t: localeT }) : null
|
|
809
|
+
return { toggle, menu, dialog }
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ── components ────────────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
function RoleBadge({ role, __t: t }) {
|
|
815
|
+
return h('span', { className: role === 'admin' ? 'um-badge um-badge-admin' : 'um-badge' },
|
|
816
|
+
role === 'admin' ? t('roleAdmin') : t('roleUser'))
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Compact token count for tight table cells. */
|
|
820
|
+
function formatTokens(value) {
|
|
821
|
+
const n = Number(value) || 0
|
|
822
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
|
823
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
|
|
824
|
+
return String(n)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Today's consumption against an account's allowance.
|
|
829
|
+
*
|
|
830
|
+
* Shows the number even when no limit is set, so an administrator can see what
|
|
831
|
+
* an account actually uses before deciding what to cap it at.
|
|
832
|
+
*/
|
|
833
|
+
function usageSummary(user, t) {
|
|
834
|
+
const usage = user.usage
|
|
835
|
+
if (!usage) return ''
|
|
836
|
+
const perms = user.permissions || {}
|
|
837
|
+
const minutes = Math.floor((usage.activeSeconds || 0) / 60)
|
|
838
|
+
const tokens = usage.hourlyTokens || 0
|
|
839
|
+
const bits = []
|
|
840
|
+
if (perms.dailyMinuteLimit != null) bits.push(interpolate(t('usageMinutes'), { used: minutes, limit: perms.dailyMinuteLimit }))
|
|
841
|
+
else if (minutes > 0) bits.push(interpolate(t('usageMinutesFree'), { used: minutes }))
|
|
842
|
+
if (perms.hourlyTokenLimit != null) bits.push(interpolate(t('usageTokens'), { used: formatTokens(tokens), limit: formatTokens(perms.hourlyTokenLimit) }))
|
|
843
|
+
else if (tokens > 0) bits.push(interpolate(t('usageTokensFree'), { used: formatTokens(tokens) }))
|
|
844
|
+
return bits.join(' · ')
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Whether today's consumption has reached a configured limit. */
|
|
848
|
+
function overQuota(user) {
|
|
849
|
+
const usage = user.usage
|
|
850
|
+
if (!usage) return false
|
|
851
|
+
const perms = user.permissions || {}
|
|
852
|
+
const byTime = perms.dailyMinuteLimit != null && (usage.activeSeconds || 0) >= perms.dailyMinuteLimit * 60
|
|
853
|
+
const byTokens = perms.hourlyTokenLimit != null && (usage.hourlyTokens || 0) >= perms.hourlyTokenLimit
|
|
854
|
+
return byTime || byTokens
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** One-line limits summary for the users table. */
|
|
858
|
+
function permissionSummary(user, t) {
|
|
859
|
+
const p = user.permissions || {}
|
|
860
|
+
const bits = []
|
|
861
|
+
if (p.banned) bits.push(t('permBannedShort'))
|
|
862
|
+
if (Array.isArray(p.allowedFolders) && p.allowedFolders.length > 0) {
|
|
863
|
+
bits.push(interpolate(t('permFoldersShort'), { n: p.allowedFolders.length }))
|
|
864
|
+
}
|
|
865
|
+
if (p.hourlyTokenLimit != null) bits.push(interpolate(t('permTokensShort'), { n: p.hourlyTokenLimit }))
|
|
866
|
+
if (p.dailyMinuteLimit != null) bits.push(interpolate(t('permMinutesShort'), { n: p.dailyMinuteLimit }))
|
|
867
|
+
if (p.sandboxMode) bits.push(p.sandboxMode)
|
|
868
|
+
if (p.allowUpload === false) bits.push(t('permNoUploadShort'))
|
|
869
|
+
if (p.allowGit === false) bits.push(t('permNoGitShort'))
|
|
870
|
+
return bits.length === 0 ? t('permUnrestricted') : bits.join(' · ')
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Per-user permissions & quota editor (admin only).
|
|
874
|
+
*
|
|
875
|
+
* This is the account model that used to live in dsh-passwords: workspace
|
|
876
|
+
* scope, an hourly token cap, daily minutes, a sandbox floor, upload/git
|
|
877
|
+
* switches and the policy ban. The store's reading is surfaced verbatim in the
|
|
878
|
+
* form: an empty folder list means every workspace, an empty limit means no
|
|
879
|
+
* cap — so clearing a field is a visible, deliberate act rather than a silent
|
|
880
|
+
* default. */
|
|
881
|
+
function PermissionsDialog({ user, onClose, onSaved, __t: t }) {
|
|
882
|
+
const p = user.permissions || {}
|
|
883
|
+
const [folders, setFolders] = useState(Array.isArray(p.allowedFolders) ? p.allowedFolders.slice() : [])
|
|
884
|
+
const [draft, setDraft] = useState('')
|
|
885
|
+
const [tokenLimit, setTokenLimit] = useState(p.hourlyTokenLimit == null ? '' : String(p.hourlyTokenLimit))
|
|
886
|
+
const [minuteLimit, setMinuteLimit] = useState(p.dailyMinuteLimit == null ? '' : String(p.dailyMinuteLimit))
|
|
887
|
+
const [sandbox, setSandbox] = useState(p.sandboxMode || '')
|
|
888
|
+
const [allowUpload, setAllowUpload] = useState(p.allowUpload !== false)
|
|
889
|
+
const [allowGit, setAllowGit] = useState(p.allowGit !== false)
|
|
890
|
+
const [banned, setBanned] = useState(p.banned === true)
|
|
891
|
+
const [note, setNote] = useState(typeof p.note === 'string' ? p.note : '')
|
|
892
|
+
const [busy, setBusy] = useState(false)
|
|
893
|
+
const [err, setErr] = useState('')
|
|
894
|
+
|
|
895
|
+
const addFolder = () => {
|
|
896
|
+
const value = draft.trim()
|
|
897
|
+
if (value === '' || folders.includes(value)) { setDraft(''); return }
|
|
898
|
+
setFolders([...folders, value])
|
|
899
|
+
setDraft('')
|
|
900
|
+
}
|
|
901
|
+
const toLimit = (value) => (String(value).trim() === '' ? null : Number(value))
|
|
902
|
+
|
|
903
|
+
const submit = () => {
|
|
904
|
+
if (busy) return
|
|
905
|
+
setBusy(true)
|
|
906
|
+
setErr('')
|
|
907
|
+
api(`/users/${user.id}/permissions`, {
|
|
908
|
+
method: 'PUT',
|
|
909
|
+
body: {
|
|
910
|
+
allowedFolders: folders,
|
|
911
|
+
hourlyTokenLimit: toLimit(tokenLimit),
|
|
912
|
+
dailyMinuteLimit: toLimit(minuteLimit),
|
|
913
|
+
sandboxMode: sandbox === '' ? null : sandbox,
|
|
914
|
+
allowUpload,
|
|
915
|
+
allowGit,
|
|
916
|
+
banned,
|
|
917
|
+
note,
|
|
918
|
+
},
|
|
919
|
+
})
|
|
920
|
+
.then(() => { onSaved() })
|
|
921
|
+
.catch((e) => setErr(String(e && e.message)))
|
|
922
|
+
.finally(() => setBusy(false))
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return h(UmDialog, {
|
|
926
|
+
title: `${t('permissionsTitle')} · ${user.username}`,
|
|
927
|
+
onClose,
|
|
928
|
+
footer: [
|
|
929
|
+
h('button', { className: 'um-btn', onClick: onClose }, '✕'),
|
|
930
|
+
h('button', { className: 'um-btn um-btn-primary', onClick: submit, disabled: busy }, t('save')),
|
|
931
|
+
],
|
|
932
|
+
},
|
|
933
|
+
err && h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, marginBottom: 6 } }, err),
|
|
934
|
+
// Setting a limit is a judgement about consumption, so show it: an
|
|
935
|
+
// administrator otherwise has to guess what "100k tokens an hour" means for
|
|
936
|
+
// this particular account.
|
|
937
|
+
usageSummary(user, t)
|
|
938
|
+
? h('div', { className: 'um-muted', style: { fontSize: 11, marginBottom: 8 } }, interpolate(t('usageToday'), { summary: usageSummary(user, t) }))
|
|
939
|
+
: null,
|
|
940
|
+
h('div', { className: 'um-form', style: { maxWidth: 'none' } },
|
|
941
|
+
h('label', null, t('permFolders')),
|
|
942
|
+
h('div', { className: 'um-muted', style: { fontSize: 11, marginTop: -4 } }, t('permFoldersHint')),
|
|
943
|
+
folders.length === 0
|
|
944
|
+
? h('div', { className: 'um-empty' }, t('permFoldersAll'))
|
|
945
|
+
: folders.map((folder, index) => h('div', { key: folder, className: 'um-row' },
|
|
946
|
+
h('code', { style: { flex: 1, wordBreak: 'break-all', fontSize: 12 } }, folder),
|
|
947
|
+
h('button', {
|
|
948
|
+
className: 'um-btn um-btn-danger', style: { flex: 'none' },
|
|
949
|
+
onClick: () => setFolders(folders.filter((_, i) => i !== index)),
|
|
950
|
+
}, t('remove')))),
|
|
951
|
+
h('div', { className: 'um-row' },
|
|
952
|
+
h('input', {
|
|
953
|
+
className: 'um-input', style: { flex: 1 }, placeholder: t('permFolderPlaceholder'),
|
|
954
|
+
value: draft, onChange: (e) => setDraft(e.target.value),
|
|
955
|
+
onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addFolder() } },
|
|
956
|
+
}),
|
|
957
|
+
h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: addFolder }, t('add'))),
|
|
958
|
+
|
|
959
|
+
h('label', { htmlFor: 'um-perm-tokens' }, t('permTokenLimit')),
|
|
960
|
+
h('input', {
|
|
961
|
+
id: 'um-perm-tokens', className: 'um-input', inputMode: 'numeric',
|
|
962
|
+
placeholder: t('permUnlimited'), value: tokenLimit, onChange: (e) => setTokenLimit(e.target.value),
|
|
963
|
+
}),
|
|
964
|
+
h('label', { htmlFor: 'um-perm-minutes' }, t('permMinuteLimit')),
|
|
965
|
+
h('input', {
|
|
966
|
+
id: 'um-perm-minutes', className: 'um-input', inputMode: 'numeric',
|
|
967
|
+
placeholder: t('permUnlimited'), value: minuteLimit, onChange: (e) => setMinuteLimit(e.target.value),
|
|
968
|
+
}),
|
|
969
|
+
|
|
970
|
+
h('label', { htmlFor: 'um-perm-sandbox' }, t('permSandbox')),
|
|
971
|
+
h('select', {
|
|
972
|
+
id: 'um-perm-sandbox', className: 'um-input', value: sandbox,
|
|
973
|
+
onChange: (e) => setSandbox(e.target.value),
|
|
974
|
+
},
|
|
975
|
+
h('option', { value: '' }, t('permSandboxNone')),
|
|
976
|
+
h('option', { value: 'read-only' }, t('permSandboxReadOnly')),
|
|
977
|
+
h('option', { value: 'workspace-write' }, t('permSandboxWorkspace')),
|
|
978
|
+
h('option', { value: 'danger-full-access' }, t('permSandboxFull'))),
|
|
979
|
+
|
|
980
|
+
h('label', { className: 'um-row', style: { gap: 6 } },
|
|
981
|
+
h('input', { type: 'checkbox', checked: allowUpload, onChange: (e) => setAllowUpload(e.target.checked) }),
|
|
982
|
+
t('permAllowUpload')),
|
|
983
|
+
h('label', { className: 'um-row', style: { gap: 6 } },
|
|
984
|
+
h('input', { type: 'checkbox', checked: allowGit, onChange: (e) => setAllowGit(e.target.checked) }),
|
|
985
|
+
t('permAllowGit')),
|
|
986
|
+
h('label', { className: 'um-row', style: { gap: 6 } },
|
|
987
|
+
h('input', { type: 'checkbox', checked: banned, onChange: (e) => setBanned(e.target.checked) }),
|
|
988
|
+
t('permBanned')),
|
|
989
|
+
|
|
990
|
+
h('label', { htmlFor: 'um-perm-note' }, t('permNote')),
|
|
991
|
+
h('input', {
|
|
992
|
+
id: 'um-perm-note', className: 'um-input', maxLength: 200,
|
|
993
|
+
value: note, onChange: (e) => setNote(e.target.value),
|
|
994
|
+
})))
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function UsersTab({ me, __t: t, flash }) {
|
|
998
|
+
const [users, setUsers] = useState(null)
|
|
999
|
+
const [temp, setTemp] = useState(null) // {name, pwd}
|
|
1000
|
+
const [busyId, setBusyId] = useState(null)
|
|
1001
|
+
const [copied, setCopied] = useState(false)
|
|
1002
|
+
const [creating, setCreating] = useState(false)
|
|
1003
|
+
const [permUser, setPermUser] = useState(null)
|
|
1004
|
+
const [form, setForm] = useState({ username: '', password: '', role: 'user' })
|
|
1005
|
+
|
|
1006
|
+
const load = () => api('/users').then((d) => setUsers(d.users)).catch((e) => flash(t('failed') + e.message))
|
|
1007
|
+
useEffect(() => { load() }, [])
|
|
1008
|
+
|
|
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
|
+
const submitCreate = (e) => {
|
|
1064
|
+
e.preventDefault()
|
|
1065
|
+
api('/users', { method: 'POST', body: form })
|
|
1066
|
+
.then(() => { setCreating(false); setForm({ username: '', password: '', role: 'user' }); flash(t('userCreated')); return load() })
|
|
1067
|
+
.catch((err) => flash(t('failed') + err.message))
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const copyTemp = () => {
|
|
1071
|
+
try {
|
|
1072
|
+
navigator.clipboard.writeText(temp.pwd).then(() => setCopied(true), () => {})
|
|
1073
|
+
} catch { /* clipboard unavailable */ }
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (users === null) return h('div', { className: 'um-card um-muted' }, t('loading'))
|
|
1077
|
+
|
|
1078
|
+
return h('div', { className: 'um-card' },
|
|
1079
|
+
h('div', { className: 'um-head', style: { marginBottom: 10 } },
|
|
1080
|
+
h('h3', { className: 'um-title' }, interpolate(t('userCount'), { n: users.length })),
|
|
1081
|
+
h('div', { className: 'um-row' },
|
|
1082
|
+
h('button', { className: 'um-btn um-btn-primary', onClick: () => setCreating(true) }, t('createUser')),
|
|
1083
|
+
h('button', { className: 'um-btn', onClick: load }, t('reload')))),
|
|
1084
|
+
h('div', { className: 'um-table-wrap' },
|
|
1085
|
+
h('table', { className: 'um-table' },
|
|
1086
|
+
h('thead', null, h('tr', null,
|
|
1087
|
+
h('th', null, t('colUser')),
|
|
1088
|
+
h('th', null, t('colRole')),
|
|
1089
|
+
h('th', null, t('colCreatedAt')),
|
|
1090
|
+
h('th', null, t('colLastLogin')),
|
|
1091
|
+
h('th', null, t('colLimits')),
|
|
1092
|
+
h('th', null, ''))),
|
|
1093
|
+
h('tbody', null, users.map((user) => h('tr', { key: user.id, style: user.disabled ? { opacity: 0.55 } : undefined },
|
|
1094
|
+
h('td', null, user.username, user.id === me.id ? t('you') : null),
|
|
1095
|
+
h('td', null, h('span', { className: 'um-row', style: { gap: 4 } },
|
|
1096
|
+
h(RoleBadge, { role: user.role, __t: t }),
|
|
1097
|
+
user.disabled ? h('span', { className: 'um-badge um-badge-err' }, t('badgeDisabled')) : null,
|
|
1098
|
+
user.totpEnabled ? h('span', { className: 'um-badge um-badge-ok' }, t('totpBadge')) : null)),
|
|
1099
|
+
h('td', { className: 'um-muted' }, formatTime(user.createdAt)),
|
|
1100
|
+
h('td', { className: 'um-muted' }, formatTime(user.lastLoginAt)),
|
|
1101
|
+
h('td', null, h('div', { style: { display: 'flex', flexDirection: 'column', gap: 3 } },
|
|
1102
|
+
h('div', { className: 'um-row', style: { gap: 4 } },
|
|
1103
|
+
h('span', {
|
|
1104
|
+
className: user.permissions && user.permissions.banned ? 'um-badge um-badge-err' : 'um-badge',
|
|
1105
|
+
title: permissionSummary(user, t),
|
|
1106
|
+
}, permissionSummary(user, t)),
|
|
1107
|
+
overQuota(user) ? h('span', { className: 'um-badge um-badge-err' }, t('usageOver')) : null),
|
|
1108
|
+
usageSummary(user, t)
|
|
1109
|
+
? h('div', { className: 'um-muted', style: { fontSize: 11, whiteSpace: 'nowrap' } }, usageSummary(user, t))
|
|
1110
|
+
: null)),
|
|
1111
|
+
h('td', null, h('div', { className: 'um-row', style: { justifyContent: 'flex-end' } },
|
|
1112
|
+
h('button', { className: 'um-btn', disabled: busyId === user.id, onClick: () => setPermUser(user) }, t('actionPermissions')),
|
|
1113
|
+
h('button', { className: 'um-btn', disabled: busyId === user.id, onClick: () => resetPwd(user) }, t('actionResetPwd')),
|
|
1114
|
+
h('button', {
|
|
1115
|
+
className: 'um-btn', disabled: busyId === user.id || user.id === me.id,
|
|
1116
|
+
title: user.id === me.id ? '-' : undefined, onClick: () => changeRole(user),
|
|
1117
|
+
}, user.role === 'admin' ? t('actionSetUser') : t('actionSetAdmin')),
|
|
1118
|
+
h('button', {
|
|
1119
|
+
className: user.disabled ? 'um-btn' : 'um-btn um-btn-danger',
|
|
1120
|
+
disabled: busyId === user.id || user.id === me.id,
|
|
1121
|
+
title: user.id === me.id ? '-' : undefined,
|
|
1122
|
+
onClick: () => toggleDisabled(user),
|
|
1123
|
+
}, user.disabled ? t('actionEnable') : t('actionDisable')),
|
|
1124
|
+
user.totpEnabled ? h('button', {
|
|
1125
|
+
className: 'um-btn', disabled: busyId === user.id,
|
|
1126
|
+
title: t('totpResetAction'), onClick: () => resetTotp(user),
|
|
1127
|
+
}, t('totpResetAction')) : null,
|
|
1128
|
+
h('button', {
|
|
1129
|
+
className: 'um-btn um-btn-danger', disabled: busyId === user.id || user.id === me.id,
|
|
1130
|
+
onClick: () => remove(user),
|
|
1131
|
+
}, t('actionDelete'))))))))),
|
|
1132
|
+
creating && h(UmDialog, {
|
|
1133
|
+
title: t('createUserTitle'), onClose: () => setCreating(false),
|
|
1134
|
+
footer: [
|
|
1135
|
+
h('button', { className: 'um-btn', onClick: () => setCreating(false) }, '✕'),
|
|
1136
|
+
h('button', { className: 'um-btn um-btn-primary', onClick: submitCreate }, t('submitCreate')),
|
|
1137
|
+
],
|
|
1138
|
+
},
|
|
1139
|
+
h('form', { className: 'um-form', onSubmit: submitCreate },
|
|
1140
|
+
h('label', { htmlFor: 'um-new-username' }, t('colUser')),
|
|
1141
|
+
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
|
+
h('label', { htmlFor: 'um-new-password' }, t('colPassword')),
|
|
1143
|
+
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 }),
|
|
1144
|
+
h('label', { htmlFor: 'um-new-role' }, t('colRolePick')),
|
|
1145
|
+
h('select', { id: 'um-new-role', className: 'um-input', value: form.role, onChange: (e) => setForm({ ...form, role: e.target.value }) },
|
|
1146
|
+
h('option', { value: 'user' }, t('roleUser')),
|
|
1147
|
+
h('option', { value: 'admin' }, t('roleAdmin'))))),
|
|
1148
|
+
permUser && h(PermissionsDialog, {
|
|
1149
|
+
user: permUser,
|
|
1150
|
+
onClose: () => setPermUser(null),
|
|
1151
|
+
onSaved: () => { setPermUser(null); flash(t('opOk')); void load() },
|
|
1152
|
+
__t: t,
|
|
1153
|
+
}),
|
|
1154
|
+
temp && h(UmDialog, {
|
|
1155
|
+
title: t('tempPwdTitle'), onClose: () => setTemp(null),
|
|
1156
|
+
footer: [
|
|
1157
|
+
h('button', { className: 'um-btn', onClick: copyTemp }, copied ? t('copied') : t('copy')),
|
|
1158
|
+
h('button', { className: 'um-btn um-btn-primary', onClick: () => setTemp(null) }, 'OK'),
|
|
1159
|
+
],
|
|
1160
|
+
},
|
|
1161
|
+
h('p', { className: 'um-muted', style: { margin: 0 } }, interpolate(t('tempPwdHint'), { name: temp.name })),
|
|
1162
|
+
h('div', { className: 'um-temp-pwd' }, temp.pwd)))
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function ActivityTab({ kind, __t: t, flash }) {
|
|
1166
|
+
const [entries, setEntries] = useState(null)
|
|
1167
|
+
const [username, setUsername] = useState('')
|
|
1168
|
+
const [type, setType] = useState('')
|
|
1169
|
+
const [selfIp, setSelfIp] = useState('')
|
|
1170
|
+
|
|
1171
|
+
const load = () => api('/activity?limit=200').then((d) => setEntries(d.entries)).catch((e) => setEntries([]) /* flash handled by empty state */)
|
|
1172
|
+
useEffect(() => {
|
|
1173
|
+
load()
|
|
1174
|
+
api('/bans').then((d) => setSelfIp(d.selfIp)).catch(() => {})
|
|
1175
|
+
}, [])
|
|
1176
|
+
|
|
1177
|
+
const visible = useMemo(
|
|
1178
|
+
() => filterActivity(entries, { username: username.trim() || null, type: type || null })
|
|
1179
|
+
.filter((entry) => (kind === 'accessLog' ? entry.type === 'access' : entry.type !== 'access')),
|
|
1180
|
+
[entries, username, type, kind])
|
|
1181
|
+
|
|
1182
|
+
return h('div', { className: 'um-card' },
|
|
1183
|
+
h('div', { className: 'um-head', style: { marginBottom: 10 } },
|
|
1184
|
+
h('div', { className: 'um-row' },
|
|
1185
|
+
h('input', { className: 'um-input', placeholder: t('filterUser'), value: username, onChange: (e) => setUsername(e.target.value) }),
|
|
1186
|
+
kind === 'loginLog' && h('select', { className: 'um-input', value: type, onChange: (e) => setType(e.target.value) },
|
|
1187
|
+
h('option', { value: '' }, t('filterAll')),
|
|
1188
|
+
Object.entries(TYPE_LABELS).filter(([key]) => key !== 'access').map(([key, labelKey]) =>
|
|
1189
|
+
h('option', { key, value: key }, t(labelKey))))),
|
|
1190
|
+
h('button', { className: 'um-btn', onClick: load }, t('reload'))),
|
|
1191
|
+
entries === null
|
|
1192
|
+
? h('div', { className: 'um-muted' }, t('loading'))
|
|
1193
|
+
: visible.length === 0
|
|
1194
|
+
? h('div', { className: 'um-empty' }, t('empty'))
|
|
1195
|
+
: h('div', { className: 'um-table-wrap' },
|
|
1196
|
+
h('table', { className: 'um-table' },
|
|
1197
|
+
h('thead', null, h('tr', null,
|
|
1198
|
+
h('th', null, t('colTime')),
|
|
1199
|
+
h('th', null, t('colType')),
|
|
1200
|
+
h('th', null, t('colUser')),
|
|
1201
|
+
h('th', null, t('colIp')),
|
|
1202
|
+
h('th', null, t('colDetail')))),
|
|
1203
|
+
h('tbody', null, visible.map((entry, i) => h('tr', { key: i },
|
|
1204
|
+
h('td', { className: 'um-muted', style: { whiteSpace: 'nowrap' } }, formatTime(entry.ts)),
|
|
1205
|
+
h('td', null, t(TYPE_LABELS[entry.type] || entry.type)),
|
|
1206
|
+
h('td', null, entry.username || '-'),
|
|
1207
|
+
h('td', { className: 'um-muted', style: { whiteSpace: 'nowrap' } }, entry.ip || '-',
|
|
1208
|
+
h(BanIpButton, { ip: entry.ip, selfIp, __t: t, flash })),
|
|
1209
|
+
h('td', { className: 'um-muted' }, entry.detail || '-')))))))
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** Admin-only operation audit: every gated API call / WS connection with
|
|
1213
|
+
* its response status, filterable by username, method, path and status.
|
|
1214
|
+
* Rows can be deleted individually; the whole ledger can be cleared. */
|
|
1215
|
+
function AuditTab({ __t: t, flash }) {
|
|
1216
|
+
const [entries, setEntries] = useState(null)
|
|
1217
|
+
const [username, setUsername] = useState('')
|
|
1218
|
+
const [method, setMethod] = useState('')
|
|
1219
|
+
const [path, setPath] = useState('')
|
|
1220
|
+
const [statusClass, setStatusClass] = useState('')
|
|
1221
|
+
const [busyId, setBusyId] = useState(null)
|
|
1222
|
+
const [selfIp, setSelfIp] = useState('')
|
|
1223
|
+
|
|
1224
|
+
const load = () => api('/audit?limit=500').then((d) => setEntries(d.entries)).catch(() => setEntries([]))
|
|
1225
|
+
useEffect(() => {
|
|
1226
|
+
load()
|
|
1227
|
+
api('/bans').then((d) => setSelfIp(d.selfIp)).catch(() => {})
|
|
1228
|
+
}, [])
|
|
1229
|
+
|
|
1230
|
+
const del = (entry) => {
|
|
1231
|
+
if (!window.confirm(t('delEntryConfirm'))) return
|
|
1232
|
+
setBusyId(entry.id)
|
|
1233
|
+
api(`/audit/${entry.id}`, { method: 'DELETE' })
|
|
1234
|
+
.then(() => { flash(t('entryDeleted')); return load() })
|
|
1235
|
+
.catch((e) => flash(t('failed') + e.message))
|
|
1236
|
+
.finally(() => setBusyId(null))
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const clearAll = () => {
|
|
1240
|
+
if (!window.confirm(t('clearLogConfirm'))) return
|
|
1241
|
+
api('/audit', { method: 'DELETE' })
|
|
1242
|
+
.then(() => { flash(t('auditCleared')); return load() })
|
|
1243
|
+
.catch((e) => flash(t('failed') + e.message))
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const visible = useMemo(
|
|
1247
|
+
() => filterAudit(entries, { username: username.trim() || null, method: method || null, path: path.trim() || null, statusClass: statusClass || null }),
|
|
1248
|
+
[entries, username, method, path, statusClass])
|
|
1249
|
+
|
|
1250
|
+
const statusBadge = (status) => {
|
|
1251
|
+
const cls = status >= 500 ? 'um-badge um-badge-err' : status >= 400 ? 'um-badge um-badge-err' : 'um-badge um-badge-ok'
|
|
1252
|
+
return h('span', { className: cls }, status ?? '-')
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
return h('div', { className: 'um-card' },
|
|
1256
|
+
h('div', { className: 'um-head', style: { marginBottom: 10 } },
|
|
1257
|
+
h('div', { className: 'um-row' },
|
|
1258
|
+
h('input', { className: 'um-input', placeholder: t('filterUser'), value: username, onChange: (e) => setUsername(e.target.value) }),
|
|
1259
|
+
h('select', { className: 'um-input', value: method, onChange: (e) => setMethod(e.target.value) },
|
|
1260
|
+
h('option', { value: '' }, t('filterAll')),
|
|
1261
|
+
['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'WS'].map((m) => h('option', { key: m, value: m }, m))),
|
|
1262
|
+
h('input', { className: 'um-input', placeholder: t('filterPath'), value: path, onChange: (e) => setPath(e.target.value) }),
|
|
1263
|
+
h('select', { className: 'um-input', value: statusClass, onChange: (e) => setStatusClass(e.target.value) },
|
|
1264
|
+
h('option', { value: '' }, t('statusAll')),
|
|
1265
|
+
h('option', { value: '2' }, t('statusOk')),
|
|
1266
|
+
h('option', { value: '4' }, t('statusErr')),
|
|
1267
|
+
h('option', { value: '5' }, t('statusErr')))),
|
|
1268
|
+
h('div', { className: 'um-row' },
|
|
1269
|
+
h('button', { className: 'um-btn', onClick: load }, t('reload')),
|
|
1270
|
+
h('button', { className: 'um-btn um-btn-danger', disabled: !entries || entries.length === 0, onClick: clearAll }, t('clearLog')))),
|
|
1271
|
+
entries === null
|
|
1272
|
+
? h('div', { className: 'um-muted' }, t('loading'))
|
|
1273
|
+
: visible.length === 0
|
|
1274
|
+
? h('div', { className: 'um-empty' }, t('empty'))
|
|
1275
|
+
: h('div', { className: 'um-table-wrap' },
|
|
1276
|
+
h('table', { className: 'um-table' },
|
|
1277
|
+
h('thead', null, h('tr', null,
|
|
1278
|
+
h('th', null, t('colTime')),
|
|
1279
|
+
h('th', null, t('colMethod')),
|
|
1280
|
+
h('th', null, t('colPath')),
|
|
1281
|
+
h('th', null, t('colUser')),
|
|
1282
|
+
h('th', null, t('colIp')),
|
|
1283
|
+
h('th', null, t('colStatus')),
|
|
1284
|
+
h('th', null, ''))),
|
|
1285
|
+
h('tbody', null, visible.map((entry, i) => h('tr', { key: i },
|
|
1286
|
+
h('td', { className: 'um-muted', style: { whiteSpace: 'nowrap' } }, formatTime(entry.ts)),
|
|
1287
|
+
h('td', null, entry.type === 'ws' ? t('typeWsConn') : entry.method || '-'),
|
|
1288
|
+
h('td', { className: 'um-muted', style: { wordBreak: 'break-all' } }, entry.path || '-'),
|
|
1289
|
+
h('td', null, entry.username || '-'),
|
|
1290
|
+
h('td', null, entry.ip || '-', h(BanIpButton, { ip: entry.ip, selfIp, __t: t, flash })),
|
|
1291
|
+
h('td', null, entry.type === 'ws' ? h('span', { className: 'um-badge' }, t('typeWsConn')) : statusBadge(entry.status)),
|
|
1292
|
+
h('td', null, h('button', {
|
|
1293
|
+
className: 'um-btn um-btn-danger', disabled: busyId === entry.id,
|
|
1294
|
+
onClick: () => del(entry),
|
|
1295
|
+
}, t('actionDelete')))))))))
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Admin-only IP bans: gate-level deny list checked before sessions —
|
|
1299
|
+
* banned addresses can't even load the login page. */
|
|
1300
|
+
function BansTab({ __t: t, flash }) {
|
|
1301
|
+
const [bans, setBans] = useState(null)
|
|
1302
|
+
const [ip, setIp] = useState('')
|
|
1303
|
+
const [note, setNote] = useState('')
|
|
1304
|
+
const [selfIp, setSelfIp] = useState('')
|
|
1305
|
+
const [busy, setBusy] = useState(false)
|
|
1306
|
+
|
|
1307
|
+
const load = () => api('/bans').then((d) => { setBans(d.bans); setSelfIp(d.selfIp) }).catch(() => setBans([]))
|
|
1308
|
+
useEffect(() => { load() }, [])
|
|
1309
|
+
|
|
1310
|
+
const ban = (e) => {
|
|
1311
|
+
e.preventDefault()
|
|
1312
|
+
const target = ip.trim()
|
|
1313
|
+
if (!target) return
|
|
1314
|
+
if (!window.confirm(interpolate(t('banConfirm'), { ip: target }))) return
|
|
1315
|
+
setBusy(true)
|
|
1316
|
+
api('/bans', { method: 'POST', body: { ip: target, note: note.trim() } })
|
|
1317
|
+
.then(() => { flash(t('banned')); setIp(''); setNote(''); return load() })
|
|
1318
|
+
.catch((err) => flash(t('failed') + err.message))
|
|
1319
|
+
.finally(() => setBusy(false))
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const unban = (entry) => {
|
|
1323
|
+
if (!window.confirm(interpolate(t('unbanConfirm'), { ip: entry.ip }))) return
|
|
1324
|
+
api(`/bans/${encodeURIComponent(entry.ip)}`, { method: 'DELETE' })
|
|
1325
|
+
.then(() => { flash(t('unbanned')); return load() })
|
|
1326
|
+
.catch((err) => flash(t('failed') + err.message))
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
return h('div', { className: 'um-card' },
|
|
1330
|
+
h('form', { className: 'um-row', style: { marginBottom: 10 }, onSubmit: ban },
|
|
1331
|
+
h('input', { className: 'um-input', placeholder: t('banIpPlaceholder'), value: ip, onChange: (e) => setIp(e.target.value), required: true, style: { width: 220 } }),
|
|
1332
|
+
h('input', { className: 'um-input', placeholder: t('banNotePlaceholder'), value: note, onChange: (e) => setNote(e.target.value), style: { width: 200 } }),
|
|
1333
|
+
h('button', { className: 'um-btn um-btn-danger', type: 'submit', disabled: busy }, t('banAction'))),
|
|
1334
|
+
bans === null
|
|
1335
|
+
? h('div', { className: 'um-muted' }, t('loading'))
|
|
1336
|
+
: bans.length === 0
|
|
1337
|
+
? h('div', { className: 'um-empty' }, t('empty'))
|
|
1338
|
+
: h('div', { className: 'um-table-wrap' },
|
|
1339
|
+
h('table', { className: 'um-table' },
|
|
1340
|
+
h('thead', null, h('tr', null,
|
|
1341
|
+
h('th', null, 'IP'),
|
|
1342
|
+
h('th', null, t('colNote')),
|
|
1343
|
+
h('th', null, t('colBannedBy')),
|
|
1344
|
+
h('th', null, t('colTime')),
|
|
1345
|
+
h('th', null, ''))),
|
|
1346
|
+
h('tbody', null, bans.map((entry) => h('tr', { key: entry.ip },
|
|
1347
|
+
h('td', null, entry.ip, entry.ip === selfIp ? h('span', { className: 'um-muted' }, t('selfIp')) : null),
|
|
1348
|
+
h('td', { className: 'um-muted' }, entry.note || '-'),
|
|
1349
|
+
h('td', null, entry.createdBy || '-'),
|
|
1350
|
+
h('td', { className: 'um-muted', style: { whiteSpace: 'nowrap' } }, formatTime(entry.createdAt)),
|
|
1351
|
+
h('td', null, h('button', { className: 'um-btn um-btn-danger', onClick: () => unban(entry) }, t('unbanAction')))))))))
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const CERT_FILE_PEM = 'user-management-gateway.crt'
|
|
1355
|
+
const CERT_FILE_DER = 'user-management-gateway.cer'
|
|
1356
|
+
|
|
1357
|
+
/** Trust-store import commands, per OS, against the downloaded files. */
|
|
1358
|
+
const CERT_INSTALL_COMMANDS = {
|
|
1359
|
+
mac: `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/Downloads/${CERT_FILE_PEM}`,
|
|
1360
|
+
win: `certutil -addstore -f Root "%USERPROFILE%\\Downloads\\${CERT_FILE_DER}"`,
|
|
1361
|
+
linux: `sudo cp ~/Downloads/${CERT_FILE_PEM} /usr/local/share/ca-certificates/user-management-gateway.crt && sudo update-ca-certificates`,
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** Admin card: download the gateway cert (PEM/DER), verify the fingerprint
|
|
1365
|
+
* and copy the per-OS trust-store import command. Hidden entirely when the
|
|
1366
|
+
* gateway is absent (e.g. accessing dsh web directly, not via the HTTPS
|
|
1367
|
+
* gateway) — there is nothing to bootstrap there. */
|
|
1368
|
+
function CertCard({ __t: t }) {
|
|
1369
|
+
useEffect(ensureStyles, [])
|
|
1370
|
+
const [info, setInfo] = useState(undefined) // undefined = loading, null = unavailable
|
|
1371
|
+
const [os, setOs] = useState('mac')
|
|
1372
|
+
const [copied, setCopied] = useState('')
|
|
1373
|
+
|
|
1374
|
+
useEffect(() => { api('/cert-info').then(setInfo).catch(() => setInfo(null)) }, [])
|
|
1375
|
+
if (info === null) return null
|
|
1376
|
+
|
|
1377
|
+
const copy = (tag, text) => {
|
|
1378
|
+
try {
|
|
1379
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
1380
|
+
setCopied(tag)
|
|
1381
|
+
setTimeout(() => setCopied(''), 2000)
|
|
1382
|
+
}, () => {})
|
|
1383
|
+
} catch { /* clipboard unavailable */ }
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return h('div', { className: 'um-card' },
|
|
1387
|
+
h('div', { className: 'um-head', style: { marginBottom: 10 } },
|
|
1388
|
+
h('h3', { className: 'um-title' }, t('certTitle')),
|
|
1389
|
+
h('div', { className: 'um-row' },
|
|
1390
|
+
h('a', { className: 'um-btn', href: '/user-management/api/cert', download: CERT_FILE_PEM }, t('certDownloadPem')),
|
|
1391
|
+
h('a', { className: 'um-btn', href: '/user-management/api/cert?format=der', download: CERT_FILE_DER }, t('certDownloadDer')))),
|
|
1392
|
+
h('p', { className: 'um-muted', style: { margin: '0 0 10px' } }, t('certHint')),
|
|
1393
|
+
info === undefined
|
|
1394
|
+
? h('div', { className: 'um-muted' }, t('loading'))
|
|
1395
|
+
: h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
|
|
1396
|
+
h('div', null,
|
|
1397
|
+
h('div', { className: 'um-muted', style: { fontSize: 11, marginBottom: 3 } }, t('certFingerprint')),
|
|
1398
|
+
h('div', { className: 'um-row' },
|
|
1399
|
+
h('code', { style: { fontSize: 12, wordBreak: 'break-all' } }, info.fingerprint),
|
|
1400
|
+
h('button', { className: 'um-btn', style: { padding: '0 8px', fontSize: 11 }, onClick: () => copy('fp', info.fingerprint) },
|
|
1401
|
+
copied === 'fp' ? t('certCopied') : t('copy')))),
|
|
1402
|
+
h('div', { className: 'um-row' },
|
|
1403
|
+
h('span', { className: 'um-muted' }, `${t('certValid')}:`),
|
|
1404
|
+
h('span', null, info.notAfter)),
|
|
1405
|
+
h('div', null,
|
|
1406
|
+
h('div', { className: 'um-muted', style: { fontSize: 11, marginBottom: 3 } }, t('certHosts')),
|
|
1407
|
+
h('div', { className: 'um-row' }, (info.hosts || []).map((hostName) =>
|
|
1408
|
+
h('span', { key: hostName, className: 'um-badge' }, hostName)))),
|
|
1409
|
+
h('div', null,
|
|
1410
|
+
h('div', { className: 'um-muted', style: { fontSize: 11, marginBottom: 3 } }, t('certInstall')),
|
|
1411
|
+
h('div', { className: 'um-row', style: { marginBottom: 6 } },
|
|
1412
|
+
['mac', 'win', 'linux'].map((key) => h('button', {
|
|
1413
|
+
key, className: os === key ? 'um-tab active' : 'um-tab', onClick: () => setOs(key),
|
|
1414
|
+
}, t(key === 'mac' ? 'osMac' : key === 'win' ? 'osWin' : 'osLinux')))),
|
|
1415
|
+
h('div', { className: 'um-row' },
|
|
1416
|
+
h('code', { style: { fontSize: 12, wordBreak: 'break-all', flex: 1 } }, CERT_INSTALL_COMMANDS[os]),
|
|
1417
|
+
h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: () => copy(os, CERT_INSTALL_COMMANDS[os]) },
|
|
1418
|
+
copied === os ? t('certCopied') : t('copy'))))))
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** Two-step verification self-service card — status, enrollment (QR +
|
|
1422
|
+
* manual key + live-code check) and password-confirmed disable. Used both
|
|
1423
|
+
* in MyPanel (plain users) and as its own AdminPanel tab (admins' own). */
|
|
1424
|
+
function TotpCard({ me, __t: t, flash }) {
|
|
1425
|
+
const [enabled, setEnabled] = useState(!!me.totpEnabled)
|
|
1426
|
+
const [settingUp, setSettingUp] = useState(false)
|
|
1427
|
+
const [disabling, setDisabling] = useState(false)
|
|
1428
|
+
return h('div', { className: 'um-card' },
|
|
1429
|
+
h('div', { className: 'um-head' },
|
|
1430
|
+
h('h3', { className: 'um-title' }, t('totpTitle')),
|
|
1431
|
+
h('div', { className: 'um-row' },
|
|
1432
|
+
h('span', { className: enabled ? 'um-badge um-badge-ok' : 'um-badge' }, enabled ? t('totpStatusOn') : t('totpStatusOff')),
|
|
1433
|
+
enabled
|
|
1434
|
+
? h('button', { className: 'um-btn um-btn-danger', onClick: () => setDisabling(true) }, t('totpDisableAction'))
|
|
1435
|
+
: h('button', { className: 'um-btn um-btn-primary', onClick: () => setSettingUp(true) }, t('totpEnableAction')))),
|
|
1436
|
+
h('p', { className: 'um-muted', style: { margin: '0 0 4px' } }, t('totpHint')),
|
|
1437
|
+
settingUp && h(TotpSetupDialog, {
|
|
1438
|
+
onClose: () => setSettingUp(false),
|
|
1439
|
+
onEnabled: () => { setSettingUp(false); setEnabled(true); flash(t('totpEnabledToast')) },
|
|
1440
|
+
__t: t,
|
|
1441
|
+
}),
|
|
1442
|
+
disabling && h(TotpDisableDialog, {
|
|
1443
|
+
onClose: () => setDisabling(false),
|
|
1444
|
+
onDisabled: () => { setDisabling(false); setEnabled(false); flash(t('totpDisabledToast')) },
|
|
1445
|
+
__t: t,
|
|
1446
|
+
}))
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** Enrollment dialog: fetches the pending secret, shows the QR + manual key,
|
|
1450
|
+
* then activates against a live code from the authenticator. */
|
|
1451
|
+
function TotpSetupDialog({ onClose, onEnabled, __t: t }) {
|
|
1452
|
+
const [info, setInfo] = useState(null) // { secret, otpauth, qrSvg }
|
|
1453
|
+
const [code, setCode] = useState('')
|
|
1454
|
+
const [busy, setBusy] = useState(false)
|
|
1455
|
+
const [err, setErr] = useState('')
|
|
1456
|
+
const [copied, setCopied] = useState(false)
|
|
1457
|
+
|
|
1458
|
+
useEffect(() => {
|
|
1459
|
+
api('/me/totp/setup', { method: 'POST' })
|
|
1460
|
+
.then(setInfo)
|
|
1461
|
+
.catch((e) => setErr(String(e && e.message)))
|
|
1462
|
+
}, [])
|
|
1463
|
+
|
|
1464
|
+
const copySecret = () => {
|
|
1465
|
+
try {
|
|
1466
|
+
navigator.clipboard.writeText(info.secret).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000) }, () => {})
|
|
1467
|
+
} catch { /* clipboard unavailable */ }
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const activate = () => {
|
|
1471
|
+
if (!code.trim() || busy) return
|
|
1472
|
+
setBusy(true)
|
|
1473
|
+
setErr('')
|
|
1474
|
+
api('/me/totp/activate', { method: 'POST', body: { code: code.trim() } })
|
|
1475
|
+
.then(onEnabled)
|
|
1476
|
+
.catch((e) => setErr(String(e && e.message)))
|
|
1477
|
+
.finally(() => setBusy(false))
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
return h(UmDialog, {
|
|
1481
|
+
title: t('totpEnableAction'), onClose,
|
|
1482
|
+
footer: [
|
|
1483
|
+
h('button', { className: 'um-btn', onClick: onClose }, '✕'),
|
|
1484
|
+
h('button', { className: 'um-btn um-btn-primary', onClick: activate, disabled: busy || !info }, t('totpActivateAction')),
|
|
1485
|
+
],
|
|
1486
|
+
},
|
|
1487
|
+
!info && !err && h('div', { className: 'um-muted' }, t('loading')),
|
|
1488
|
+
info && h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
|
|
1489
|
+
h('div', { className: 'um-qr', dangerouslySetInnerHTML: { __html: info.qrSvg } }),
|
|
1490
|
+
h('p', { className: 'um-muted', style: { margin: 0 } }, t('totpScanHint')),
|
|
1491
|
+
h('div', { className: 'um-row' },
|
|
1492
|
+
h('code', { style: { fontSize: 12, wordBreak: 'break-all', flex: 1 } }, info.secret),
|
|
1493
|
+
h('button', { className: 'um-btn', style: { flex: 'none' }, onClick: copySecret }, copied ? t('copied') : t('copy'))),
|
|
1494
|
+
h('label', { htmlFor: 'um-totp-code' }, t('totpCodeLabel')),
|
|
1495
|
+
h('input', {
|
|
1496
|
+
id: 'um-totp-code', className: 'um-input', inputMode: 'numeric', maxLength: 6,
|
|
1497
|
+
placeholder: t('totpCodePlaceholder'), value: code, autoFocus: true,
|
|
1498
|
+
onChange: (e) => setCode(e.target.value),
|
|
1499
|
+
onKeyDown: (e) => { if (e.key === 'Enter') activate() },
|
|
1500
|
+
})),
|
|
1501
|
+
err && h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, err))
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** Disable dialog — demands the login password so a stolen session alone
|
|
1505
|
+
* cannot weaken the account. */
|
|
1506
|
+
function TotpDisableDialog({ onClose, onDisabled, __t: t }) {
|
|
1507
|
+
const [pwd, setPwd] = useState('')
|
|
1508
|
+
const [busy, setBusy] = useState(false)
|
|
1509
|
+
const [err, setErr] = useState('')
|
|
1510
|
+
|
|
1511
|
+
const submit = () => {
|
|
1512
|
+
if (!pwd || busy) return
|
|
1513
|
+
setBusy(true)
|
|
1514
|
+
setErr('')
|
|
1515
|
+
api('/me/totp/disable', { method: 'POST', body: { password: pwd } })
|
|
1516
|
+
.then(onDisabled)
|
|
1517
|
+
.catch((e) => setErr(String(e && e.message)))
|
|
1518
|
+
.finally(() => setBusy(false))
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
return h(UmDialog, {
|
|
1522
|
+
title: t('totpDisableConfirmTitle'), onClose,
|
|
1523
|
+
footer: [
|
|
1524
|
+
h('button', { className: 'um-btn', onClick: onClose }, '✕'),
|
|
1525
|
+
h('button', { className: 'um-btn um-btn-danger', onClick: submit, disabled: busy || !pwd }, t('totpDisableAction')),
|
|
1526
|
+
],
|
|
1527
|
+
},
|
|
1528
|
+
h('form', {
|
|
1529
|
+
className: 'um-form', style: { maxWidth: 'none' },
|
|
1530
|
+
onSubmit: (e) => { e.preventDefault(); submit() },
|
|
1531
|
+
},
|
|
1532
|
+
h('label', { htmlFor: 'um-totp-disable-pwd' }, t('totpDisablePwdLabel')),
|
|
1533
|
+
h('input', {
|
|
1534
|
+
id: 'um-totp-disable-pwd', className: 'um-input', type: 'password',
|
|
1535
|
+
autoComplete: 'current-password', value: pwd, required: true, autoFocus: true,
|
|
1536
|
+
onChange: (e) => setPwd(e.target.value),
|
|
1537
|
+
}),
|
|
1538
|
+
err && h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, err)))
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function MyPanel({ me, __t: t, flash }) {
|
|
1542
|
+
const [oldPwd, setOldPwd] = useState('')
|
|
1543
|
+
const [newPwd, setNewPwd] = useState('')
|
|
1544
|
+
const [confirmPwd, setConfirmPwd] = useState('')
|
|
1545
|
+
const [busy, setBusy] = useState(false)
|
|
1546
|
+
const [entries, setEntries] = useState(null)
|
|
1547
|
+
|
|
1548
|
+
useEffect(() => { api('/activity?limit=50').then((d) => setEntries(d.entries)).catch(() => setEntries([])) }, [])
|
|
1549
|
+
|
|
1550
|
+
const submit = (e) => {
|
|
1551
|
+
e.preventDefault()
|
|
1552
|
+
if (newPwd !== confirmPwd) { flash(t('pwdMismatch')); return }
|
|
1553
|
+
setBusy(true)
|
|
1554
|
+
api('/me/password', { method: 'POST', body: { oldPassword: oldPwd, newPassword: newPwd } })
|
|
1555
|
+
.then(() => { flash(t('pwdChanged')); setOldPwd(''); setNewPwd(''); setConfirmPwd('') })
|
|
1556
|
+
.catch((err) => flash(t('failed') + err.message))
|
|
1557
|
+
.finally(() => setBusy(false))
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Observer mode: the password form and 2FA belong to the login door, so a
|
|
1561
|
+
// trusted non-admin sees their identity and their own login records only.
|
|
1562
|
+
const observer = sessionCache.observer === true
|
|
1563
|
+
|
|
1564
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
|
|
1565
|
+
h('div', { className: 'um-card' },
|
|
1566
|
+
h('div', { className: 'um-head' },
|
|
1567
|
+
h('h3', { className: 'um-title' }, t('myInfo')),
|
|
1568
|
+
observer ? null : h(LogoutButton, { withLabel: true, __t: t })),
|
|
1569
|
+
h('dl', { className: 'um-kv', style: { marginTop: 10 } },
|
|
1570
|
+
h('dt', null, t('colUser')), h('dd', null, me.username),
|
|
1571
|
+
h('dt', null, t('colRole')), h('dd', null, h(RoleBadge, { role: me.role, __t: t })),
|
|
1572
|
+
h('dt', null, t('colCreatedAt')), h('dd', null, formatTime(me.createdAt)),
|
|
1573
|
+
h('dt', null, t('colLastLogin')), h('dd', null, formatTime(me.lastLoginAt)))),
|
|
1574
|
+
observer ? null : h('form', { className: 'um-card um-form', onSubmit: submit },
|
|
1575
|
+
h('h3', { className: 'um-title' }, t('changePwd')),
|
|
1576
|
+
h('label', { htmlFor: 'um-old-pwd' }, t('oldPwd')),
|
|
1577
|
+
h('input', { id: 'um-old-pwd', className: 'um-input', type: 'password', autoComplete: 'current-password', value: oldPwd, onChange: (e) => setOldPwd(e.target.value), required: true }),
|
|
1578
|
+
h('label', { htmlFor: 'um-new-pwd' }, t('newPwd')),
|
|
1579
|
+
h('input', { id: 'um-new-pwd', className: 'um-input', type: 'password', autoComplete: 'new-password', value: newPwd, onChange: (e) => setNewPwd(e.target.value), required: true, minLength: 6 }),
|
|
1580
|
+
h('label', { htmlFor: 'um-conf-pwd' }, t('confirmPwd')),
|
|
1581
|
+
h('input', { id: 'um-conf-pwd', className: 'um-input', type: 'password', autoComplete: 'new-password', value: confirmPwd, onChange: (e) => setConfirmPwd(e.target.value), required: true }),
|
|
1582
|
+
h('div', null, h('button', { className: 'um-btn um-btn-primary', type: 'submit', disabled: busy }, t('save')))),
|
|
1583
|
+
observer ? null : h(TotpCard, { me, __t: t, flash }),
|
|
1584
|
+
h('div', { className: 'um-card' },
|
|
1585
|
+
h('h3', { className: 'um-title', style: { marginBottom: 10 } }, t('tabLoginLog')),
|
|
1586
|
+
entries === null
|
|
1587
|
+
? h('div', { className: 'um-muted' }, t('loading'))
|
|
1588
|
+
: entries.length === 0
|
|
1589
|
+
? h('div', { className: 'um-empty' }, t('empty'))
|
|
1590
|
+
: h('div', { className: 'um-table-wrap' },
|
|
1591
|
+
h('table', { className: 'um-table' },
|
|
1592
|
+
h('thead', null, h('tr', null,
|
|
1593
|
+
h('th', null, t('colTime')),
|
|
1594
|
+
h('th', null, t('colType')),
|
|
1595
|
+
h('th', null, t('colIp')))),
|
|
1596
|
+
h('tbody', null, entries.map((entry, i) => h('tr', { key: i },
|
|
1597
|
+
h('td', { className: 'um-muted', style: { whiteSpace: 'nowrap' } }, formatTime(entry.ts)),
|
|
1598
|
+
h('td', null, t(TYPE_LABELS[entry.type] || entry.type)),
|
|
1599
|
+
h('td', { className: 'um-muted' }, entry.ip || '-'))))))))
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function UserManagementSection({ __t: t }) {
|
|
1603
|
+
useEffect(ensureStyles, [])
|
|
1604
|
+
const [me, setMe] = useState(undefined) // undefined = loading, null = signed out
|
|
1605
|
+
const [toast, setToast] = useState('')
|
|
1606
|
+
const toastTimer = useRef(null)
|
|
1607
|
+
const flash = (msg) => {
|
|
1608
|
+
setToast(msg)
|
|
1609
|
+
if (toastTimer.current) clearTimeout(toastTimer.current)
|
|
1610
|
+
toastTimer.current = setTimeout(() => setToast(''), 2400)
|
|
1611
|
+
}
|
|
1612
|
+
useEffect(() => {
|
|
1613
|
+
loadMeCached().then((user) => setMe(user))
|
|
1614
|
+
}, [])
|
|
1615
|
+
|
|
1616
|
+
return h('div', { className: 'um-wrap' },
|
|
1617
|
+
toast && h('div', { className: 'um-toast' }, toast),
|
|
1618
|
+
me === undefined && h('div', { className: 'um-card um-muted' }, t('loading')),
|
|
1619
|
+
me === null && (sessionCache.direct
|
|
1620
|
+
? h('div', { className: 'um-card um-muted' }, t('directAccess'))
|
|
1621
|
+
: h('div', { className: 'um-card um-muted' }, t('notLoggedIn'))),
|
|
1622
|
+
me && (me.role === 'admin'
|
|
1623
|
+
? h(AdminPanel, { me, __t: t, flash })
|
|
1624
|
+
: h(MyPanel, { me, __t: t, flash })))
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function AdminPanel({ me, __t: t, flash }) {
|
|
1628
|
+
// Observer mode (merged deployment): dsh-passwords owns accounts, bans,
|
|
1629
|
+
// certificates and 2FA — what is left for this layer is the record: who
|
|
1630
|
+
// signed in, what was opened, what was called. So the tab strip is exactly
|
|
1631
|
+
// the three ledgers, and there is no sign-out here to offer.
|
|
1632
|
+
const observer = sessionCache.observer === true
|
|
1633
|
+
const [tab, setTab] = useState(observer ? 'loginLog' : 'users')
|
|
1634
|
+
const tabs = observer
|
|
1635
|
+
? [['loginLog', t('tabLoginLog')], ['accessLog', t('tabAccessLog')], ['auditLog', t('tabAuditLog')]]
|
|
1636
|
+
: [['users', t('tabUsers')], ['loginLog', t('tabLoginLog')], ['accessLog', t('tabAccessLog')], ['auditLog', t('tabAuditLog')], ['bans', t('tabBans')], ['cert', t('certTitle')], ['totp', t('totpTitle')]]
|
|
1637
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
|
|
1638
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 } },
|
|
1639
|
+
h('div', { className: 'um-tabs' }, tabs.map(([key, label]) =>
|
|
1640
|
+
h('button', { key, className: tab === key ? 'um-tab active' : 'um-tab', onClick: () => setTab(key) }, label))),
|
|
1641
|
+
observer ? null : h(LogoutButton, { withLabel: true, __t: t })),
|
|
1642
|
+
tab === 'users' ? h(UsersTab, { me, __t: t, flash })
|
|
1643
|
+
: tab === 'auditLog' ? h(AuditTab, { __t: t, flash })
|
|
1644
|
+
: tab === 'bans' ? h(BansTab, { __t: t, flash })
|
|
1645
|
+
: tab === 'cert' ? h(CertCard, { __t: t })
|
|
1646
|
+
: tab === 'totp' ? h(TotpCard, { me, __t: t, flash })
|
|
1647
|
+
: h(ActivityTab, { kind: tab, __t: t, flash }))
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* Invisible token-usage reporter, mounted on the session-scoped
|
|
1652
|
+
* 'conversation.composer.dock' slot (whose standard props carry
|
|
1653
|
+
* `useProjection`, the framework's projection read seat).
|
|
1654
|
+
*
|
|
1655
|
+
* Why the browser reports at all: the gateway cannot see provider accounting,
|
|
1656
|
+
* and dsh's projection is the only trustworthy source of it. The projection is
|
|
1657
|
+
* a per-session CUMULATIVE value, so this keeps the last observed total and
|
|
1658
|
+
* reports only the difference — the first observation is a baseline and is
|
|
1659
|
+
* never billed, which is what stops a session switch from re-charging the same
|
|
1660
|
+
* tokens.
|
|
1661
|
+
*
|
|
1662
|
+
* Only `uncachedInputTokens + outputTokens` are counted: dsh's projection
|
|
1663
|
+
* reports cacheReadTokens as a per-step cumulative and its aggregation replays
|
|
1664
|
+
* it as if incremental, which inflates it by orders of magnitude. Billing a
|
|
1665
|
+
* quota against that would burn a user's allowance for cached reads.
|
|
1666
|
+
*
|
|
1667
|
+
* Renders null — it exists only for its effect.
|
|
1668
|
+
*/
|
|
1669
|
+
function TokenReporter({ useProjection }) {
|
|
1670
|
+
const read = typeof useProjection === 'function' ? useProjection : null
|
|
1671
|
+
const usage = read ? read('tokenUsage') : undefined
|
|
1672
|
+
const lastTotal = useRef(null)
|
|
1673
|
+
const pending = useRef(0)
|
|
1674
|
+
const inFlight = useRef(false)
|
|
1675
|
+
|
|
1676
|
+
useEffect(() => {
|
|
1677
|
+
if (!usage) return
|
|
1678
|
+
const total = (usage.uncachedInputTokens || 0) + (usage.outputTokens || 0)
|
|
1679
|
+
if (lastTotal.current === null) {
|
|
1680
|
+
lastTotal.current = total
|
|
1681
|
+
return
|
|
1682
|
+
}
|
|
1683
|
+
const delta = total - lastTotal.current
|
|
1684
|
+
if (delta > 0) pending.current += delta
|
|
1685
|
+
lastTotal.current = total
|
|
1686
|
+
}, [usage])
|
|
1687
|
+
|
|
1688
|
+
useEffect(() => {
|
|
1689
|
+
const flush = () => {
|
|
1690
|
+
if (inFlight.current) return // a request is still in flight: retry next tick
|
|
1691
|
+
const amount = pending.current
|
|
1692
|
+
if (amount <= 0) return
|
|
1693
|
+
pending.current = 0
|
|
1694
|
+
inFlight.current = true
|
|
1695
|
+
api('/usage/report', { method: 'POST', body: { tokens: amount } })
|
|
1696
|
+
// Put the count back so a failed report is retried rather than lost.
|
|
1697
|
+
.catch(() => { pending.current += amount })
|
|
1698
|
+
.finally(() => { inFlight.current = false })
|
|
1699
|
+
}
|
|
1700
|
+
const timer = setInterval(flush, 15000)
|
|
1701
|
+
return () => { clearInterval(timer); flush() }
|
|
1702
|
+
}, [])
|
|
1703
|
+
|
|
1704
|
+
return null
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/** Settings-section entry. The console used to render inline here, which buried
|
|
1708
|
+
* a whole management surface inside the settings list; the slot now offers a
|
|
1709
|
+
* one-line door instead and the console opens as a full-page layer pinned
|
|
1710
|
+
* above the settings area. Escape or 返回 walks back to the entry. */
|
|
1711
|
+
function UserManagementEntry({ __t: t }) {
|
|
1712
|
+
useEffect(ensureStyles, [])
|
|
1713
|
+
const [open, setOpen] = useState(false)
|
|
1714
|
+
|
|
1715
|
+
useEffect(() => {
|
|
1716
|
+
if (!open) return undefined
|
|
1717
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
|
1718
|
+
document.addEventListener('keydown', onKey)
|
|
1719
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
1720
|
+
}, [open])
|
|
1721
|
+
|
|
1722
|
+
const entry = h('div', { className: 'um-card um-entry' },
|
|
1723
|
+
h('div', { className: 'um-entry-text' },
|
|
1724
|
+
h('h3', { className: 'um-title' }, t('title')),
|
|
1725
|
+
h('div', { className: 'um-muted', style: { fontSize: 12, lineHeight: 1.5 } }, t('panelHint'))),
|
|
1726
|
+
h('button', { className: 'um-btn um-btn-primary', style: { flex: 'none' }, onClick: () => setOpen(true) }, t('openPanel')))
|
|
1727
|
+
|
|
1728
|
+
if (!open) return entry
|
|
1729
|
+
|
|
1730
|
+
const page = h('div', { className: 'um-page-backdrop', role: 'dialog', 'aria-label': t('title') },
|
|
1731
|
+
h('div', { className: 'um-page-head' },
|
|
1732
|
+
h('h3', { className: 'um-page-title' }, t('title')),
|
|
1733
|
+
h('button', { className: 'um-btn', onClick: () => setOpen(false) }, t('panelBack'))),
|
|
1734
|
+
h('div', { className: 'um-page-body' },
|
|
1735
|
+
h('div', { className: 'um-page-inner' }, h(UserManagementSection, { __t: t }))))
|
|
1736
|
+
|
|
1737
|
+
return h('div', null,
|
|
1738
|
+
entry,
|
|
1739
|
+
RDP && typeof RDP.createPortal === 'function' ? RDP.createPortal(page, document.body) : page)
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// ── module wiring ─────────────────────────────────────────────────────────
|
|
1743
|
+
|
|
1744
|
+
function __boot(container) {
|
|
1745
|
+
if (!RDP || typeof RDP.createRoot !== 'function') return
|
|
1746
|
+
RDP.createRoot(container).render(h(UserManagementSection, { __t: (key, vars) => interpolate(ZH[key] || key, vars) }))
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
module.exports = {
|
|
1750
|
+
name: CLIENT_NAME,
|
|
1751
|
+
inject: ['slots', 'locale'],
|
|
1752
|
+
__boot,
|
|
1753
|
+
__internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar, BrandMark, BrandName, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
|
|
1754
|
+
apply(ctx) {
|
|
1755
|
+
ctx.locale.register(NS, 'zh', ZH)
|
|
1756
|
+
ctx.locale.register(NS, 'en', EN)
|
|
1757
|
+
const t = ctx.locale.bind(NS)
|
|
1758
|
+
localeT = t
|
|
1759
|
+
ctx.effect(() => {
|
|
1760
|
+
// Official-brand pattern: nested injects claim both brand seats, one
|
|
1761
|
+
// generator registers them. The mark shows in expanded row + collapsed
|
|
1762
|
+
// rail (fold-adaptive), the name only beside the expanded mark.
|
|
1763
|
+
// priority: -1 shadows the host's default brand registration at 0
|
|
1764
|
+
// ("lowest renders"); uninstalling this plugin restores the official
|
|
1765
|
+
// brand with no residue.
|
|
1766
|
+
ctx.slots.inject('sidebar.brand.mark', () => ctx.slots.inject('sidebar.brand.name', function* () {
|
|
1767
|
+
yield ctx.slots.register({ name: 'sidebar.brand.mark', priority: -1 }, BrandMark)
|
|
1768
|
+
yield ctx.slots.register({ name: 'sidebar.brand.name', priority: -1 }, BrandName)
|
|
1769
|
+
}))
|
|
1770
|
+
}, 'user-management: sidebar brand')
|
|
1771
|
+
// Token accounting: the session-scoped dock slot is the one seat whose
|
|
1772
|
+
// standard props carry `useProjection` — the only place the browser can
|
|
1773
|
+
// read the host's own token totals.
|
|
1774
|
+
ctx.effect(() => {
|
|
1775
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
1776
|
+
name: 'conversation.composer.dock',
|
|
1777
|
+
id: `${CLIENT_NAME}:token-reporter`,
|
|
1778
|
+
order: 90,
|
|
1779
|
+
locale: NS,
|
|
1780
|
+
}, TokenReporter))
|
|
1781
|
+
}, 'user-management: token reporter')
|
|
1782
|
+
ctx.effect(() => {
|
|
1783
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1784
|
+
name: 'settings.section',
|
|
1785
|
+
id: CLIENT_NAME,
|
|
1786
|
+
order: 92,
|
|
1787
|
+
locale: NS,
|
|
1788
|
+
label: () => t('title'),
|
|
1789
|
+
inject: () => ({}),
|
|
1790
|
+
}, function UserManagementSlot() {
|
|
1791
|
+
return h(UserManagementEntry, { __t: t })
|
|
1792
|
+
}))
|
|
1793
|
+
}, 'user-management: settings section')
|
|
1794
|
+
},
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
return module.exports
|
|
1798
|
+
}
|
|
1799
|
+
})
|