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