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.
@@ -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
+ })