dsh-webui-oauth 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,79 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.8.2
6
+
7
+ 浏览器与会话标识改为 HTTP 实时;设备授权必须声明应用。
8
+
9
+ ### 浏览器会话:标识按 HTTP 请求实时更新
10
+
11
+ - `checkRequest` 每次请求都用当前请求的 UA / IP 重新解析系统、浏览器、设备形态,
12
+ 写入会话记录(UA/IP 变化立即落盘,否则每分钟一次);
13
+ - 「浏览器会话」列表不再展示登录时的快照,而是**最近一次请求**的真实标识;
14
+ - 设置页每 15 秒自动刷新会话列表(也可手动刷新)。
15
+
16
+ ### 设备会话:申请时声明应用 + HTTP 来源
17
+
18
+ - `POST /device/authorize` **必须**携带 `client_name`(可选 `client_description`),
19
+ 未声明返回 `400 invalid_request`;
20
+ - 批准页在批准前调用 `POST /device/inspect` 显示应用名称/说明并二次确认;
21
+ - 设备记录保存声明的应用名称、说明,以及每次 Bearer 请求的 IP、UA、系统、浏览器、指纹;
22
+ - 「设备会话」列表展示:应用名称、说明、系统 · 浏览器、指纹、最近使用、来源 IP;
23
+ - 设备 Bearer 请求同样实时刷新这些字段(UA/IP 变化立即落盘)。
24
+
25
+ ### 测试
26
+
27
+ - `device-flow` 新增 8 项:未声明应用被拒、批准页可查看声明、设备列表带
28
+ 应用说明 / 来源 IP / 系统字段 / 指纹;
29
+ - `lifecycle` 新增 2 项:同一会话换 UA 后列表实时更新为 Chrome 130 / Linux;
30
+ - 全量 24 个测试文件、687 项断言通过。
31
+
32
+ ## 0.8.1
33
+
34
+ 设备批准登录回跳、浏览器会话列表、保存提示语义、DeepSeek 子窗口中转、绑定弹窗。
35
+
36
+ ### 设备批准:未登录跳转登录并正确回跳
37
+
38
+ - `GET /dsh-webui-oauth/device` 加载时检查会话;未登录自动跳
39
+ `/login?next=/dsh-webui-oauth/device?user_code=...`;
40
+ - 登录页支持 `next`:密码登录与 SSO 都会在成功后回到设备批准页(保留 user_code);
41
+ - `safeNextPath()` 只允许同源相对路径,拒绝 `//host`、反斜杠与绝对 URL。
42
+
43
+ ### 修正保存提示语义
44
+
45
+ - 只有**修改密码**时才吊销其他浏览器会话;只改 TTL/OIDC 配置不再踢人;
46
+ - 提示改为「密码已更新,已吊销 N 个其他浏览器会话(设备会话不受影响)」或
47
+ 「已保存。当前会话保持有效。」;
48
+ - `configure` 返回 `revoked` / `passwordChanged` 供前端展示。
49
+
50
+ ### 浏览器会话列表
51
+
52
+ - 会话记录新增:创建时间、最近活跃、IP、UA、系统、浏览器、设备形态;
53
+ - 设置页新增「浏览器会话」卡片:当前会话标记、系统/浏览器、设备指纹(UA+IP 短哈希)、
54
+ 最近活跃时间,可**单独吊销**;
55
+ - 新增 `GET /dsh-webui-oauth/sessions` 与 `POST /dsh-webui-oauth/sessions/revoke-one`;
56
+ - 当前会话不允许从列表吊销(提示用「退出登录」)。
57
+
58
+ ### DeepSeek 登录:子窗口中转
59
+
60
+ - 点击官方「登录」时,在同一用户手势内打开
61
+ `/dsh-webui-oauth/relay-window` 子窗口(类似 SSO 验证弹窗);
62
+ - 子窗口显示「等待验证」并接受回调 URL,成功后 `postMessage` 通知主窗口并自我关闭;
63
+ - 原「等待登录」对话框内的粘贴框保留为兜底,并新增「在新窗口打开」。
64
+
65
+ ### 绑定 / 换绑 / 解绑
66
+
67
+ - 绑定与换绑改为子窗口:`/oidc/bind?popup=1`,回调返回自我关闭页面并
68
+ `postMessage({type:'dsh-wua-bound'})`;
69
+ - 解绑改为与保存一致的验证弹窗(当前密码或 SSO 复验),移除卡片里的密码输入框。
70
+
71
+ ### 测试
72
+
73
+ - `lifecycle` 新增 16 项:登录 next、会话列表/单条吊销、保存不误吊销、
74
+ 改密码吊销其他会话;
75
+ - `oidc-bind` 新增 5 项:绑定弹窗回调 HTML/通知/自我关闭/落库;
76
+ - 全量 24 个测试文件、677 项断言通过。
77
+
5
78
  ## 0.8.0
6
79
 
7
80
  新增 **OAuth 2.1 设备授权(RFC 8628,无回调)**:App / CLI 可以获得与浏览器完全独立的设备会话,
package/README.md CHANGED
@@ -423,14 +423,19 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
423
423
  给没有浏览器 Cookie 的客户端(dsh-Go 等 App、CLI)使用的第二套凭据。流程遵循 RFC 8628,**无回调**:
424
424
 
425
425
  1. `POST /dsh-webui-oauth/device/authorize`(公开)→ `device_code`、`user_code`、`verification_uri`;
426
- 2. 用户在浏览器打开 `verification_uri`,登录后输入 `user_code` 批准(或拒绝);
426
+ **必须带 `client_name` 声明应用**(可选 `client_description`),否则返回 `invalid_request`;
427
+ 2. 用户在浏览器打开 `verification_uri`;未登录会自动跳转登录页(密码或 SSO),
428
+ 登录成功后回到批准页并保留 `user_code`,再批准(或拒绝);
427
429
  3. `POST /dsh-webui-oauth/device/token`(公开)轮询 → `access_token`(Bearer)。
428
430
 
429
431
  设备会话与浏览器会话**完全独立**:
430
432
 
431
433
  - 单独存放于 `devices.jsonl`,只存 token 的 SHA-256;
434
+ - 记录**申请时声明的应用名称/说明**,以及每次 Bearer 请求的 IP、UA、系统、浏览器、指纹;
432
435
  - **没有过期时间**,只有显式吊销(`POST /dsh-webui-oauth/devices/revoke`)才失效;
433
436
  - 设置页「设备会话(App / CLI)」单独列表,可逐个吊销;
437
+ - 浏览器会话也有独立列表(系统 / 浏览器 / 设备指纹 / 最近活跃),可单独吊销;
438
+ 标识按**每个 HTTP 请求**实时刷新,不是登录时的快照;
434
439
  - 设备 Bearer 直接通过 `checkRequest`,可访问 `/api`、`/plugins`、WebSocket 与静态资源;
435
440
  - 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话)。
436
441
 
@@ -448,7 +453,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
448
453
  - **DeepSeek 官方账号登录(0.2.1+,远程回调中转)**:`/oauth/callback` **不是公开路径**,仍受会话闸门保护。上游 `@deepseek-ai/dsh-deepseek-account-platform` 的 `loginOrigin()` 只接受 `http://localhost:<port>`、`http://127.0.0.1:<port>`、`http://[::1]:<port>`(显式非零端口),不支持反代域名。插件的处理方式:
449
454
  - 劫持官方登录的 `callbackOrigin`:本地回环访问保留原 origin(cookie 同 host,回调直接命中);域名访问改写成 `http://127.0.0.1:<本机端口>`;
450
455
  - 浏览器授权后会被跳到这个回环回调地址——远程机器上通常打不开,但地址栏里有完整 URL;
451
- - 官方「等待登录」窗口里会直接出现一个回调粘贴框(插件用 MutationObserver 注入到该对话框内,不单独占一个悬浮入口);把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
456
+ - 点击官方「登录」时会打开 `/dsh-webui-oauth/relay-window` 子窗口(类似 SSO 验证弹窗);授权后把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;「等待登录」对话框内的粘贴框保留为兜底;
452
457
  - 中转端点要求插件会话,且只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`(防 SSRF / 开放重定向);
453
458
  - 本机 `http://127.0.0.1:3080` 访问时官方登录原本就能直接走完,无需中转。
454
459
  - **DSH Desktop**:支持 `dsh-desktop-*` 标记透传(登录 / 退出后重登不再丢失标记),但**不提供**上游 0.3.6 的「宿主载体桥」。该桥只用 socket 是否回环来判定,而本部署是 nginx 同机反代——所有请求的 socket 都是 `127.0.0.1`,启用它等于"持有核心 Cookie 即可绕过插件会话 / SSO",与「仅允许 SSO」冲突。若必须使用 DSH Desktop 的 shell 直连模式,请改用上游原版插件,并自行评估该旁路风险。
package/index.js CHANGED
@@ -649,6 +649,52 @@ function requestMeta(req) {
649
649
  return { ip, ua }
650
650
  }
651
651
 
652
+ /** 从 UA 粗略解析系统 / 浏览器 / 设备形态(用于会话列表展示,不做精确指纹)。 */
653
+ function describeUserAgent(ua) {
654
+ const s = String(ua || '')
655
+ let os = '未知系统'
656
+ if (/Windows NT 10\.0/.test(s)) os = 'Windows 10/11'
657
+ else if (/Windows NT 6\.3/.test(s)) os = 'Windows 8.1'
658
+ else if (/Windows/.test(s)) os = 'Windows'
659
+ else if (/Android/.test(s)) {
660
+ const m = /Android\s([\d.]+)/.exec(s)
661
+ os = 'Android' + (m ? ' ' + m[1] : '')
662
+ } else if (/iPhone|iPad|iPod/.test(s)) {
663
+ const m = /OS\s([\d_]+)/.exec(s)
664
+ os = 'iOS' + (m ? ' ' + m[1].replace(/_/g, '.') : '')
665
+ } else if (/Mac OS X/.test(s)) {
666
+ const m = /Mac OS X\s([\d_.]+)/.exec(s)
667
+ os = 'macOS' + (m ? ' ' + m[1].replace(/_/g, '.') : '')
668
+ } else if (/CrOS/.test(s)) os = 'ChromeOS'
669
+ else if (/Linux/.test(s)) os = 'Linux'
670
+
671
+ let browser = '未知浏览器'
672
+ if (/Edg\/([\d.]+)/.test(s)) browser = 'Edge ' + /Edg\/([\d.]+)/.exec(s)[1].split('.')[0]
673
+ else if (/OPR\/([\d.]+)/.test(s)) browser = 'Opera ' + /OPR\/([\d.]+)/.exec(s)[1].split('.')[0]
674
+ else if (/Chrome\/([\d.]+)/.test(s)) browser = 'Chrome ' + /Chrome\/([\d.]+)/.exec(s)[1].split('.')[0]
675
+ else if (/Firefox\/([\d.]+)/.test(s)) browser = 'Firefox ' + /Firefox\/([\d.]+)/.exec(s)[1].split('.')[0]
676
+ else if (/Version\/([\d.]+).*Safari/.test(s)) browser = 'Safari ' + /Version\/([\d.]+)/.exec(s)[1].split('.')[0]
677
+ else if (/Safari/.test(s)) browser = 'Safari'
678
+
679
+ let device = '桌面'
680
+ if (/iPad|Tablet/.test(s)) device = '平板'
681
+ else if (/Mobile|iPhone|Android.*Mobile/.test(s)) device = '手机'
682
+ return { os, browser, device }
683
+ }
684
+
685
+ /** 会话在列表里的稳定短 id(不暴露原始 token)。 */
686
+ function sessionShortId(token) {
687
+ return createHash('sha256').update(String(token)).digest('hex').slice(0, 16)
688
+ }
689
+
690
+ /** 设备指纹:UA + IP 的短哈希(同一浏览器+出口 IP 稳定)。 */
691
+ function sessionFingerprint(sess) {
692
+ return createHash('sha256')
693
+ .update(String((sess && sess.ua) || '') + '|' + String((sess && sess.ip) || ''))
694
+ .digest('hex')
695
+ .slice(0, 12)
696
+ }
697
+
652
698
  async function readAuditEntries(ctx, limit) {
653
699
  let target = null
654
700
  try {
@@ -804,7 +850,18 @@ class PersistentSessions {
804
850
  try { ev = JSON.parse(line) } catch (e) { continue }
805
851
  if (!ev || typeof ev.op !== 'string' || typeof ev.token !== 'string') continue
806
852
  if (ev.op === 'add' && ev.sess && typeof ev.sess === 'object') {
807
- const s = { username: String(ev.sess.username || ''), expiresAt: Number(ev.sess.expiresAt) || 0, browser: !!ev.sess.browser }
853
+ const s = {
854
+ username: String(ev.sess.username || ''),
855
+ expiresAt: Number(ev.sess.expiresAt) || 0,
856
+ browser: !!ev.sess.browser,
857
+ createdAt: Number(ev.sess.createdAt) || 0,
858
+ lastSeenAt: Number(ev.sess.lastSeenAt) || 0,
859
+ ip: typeof ev.sess.ip === 'string' ? ev.sess.ip : null,
860
+ ua: typeof ev.sess.ua === 'string' ? ev.sess.ua : null,
861
+ os: typeof ev.sess.os === 'string' ? ev.sess.os : '',
862
+ browserName: typeof ev.sess.browserName === 'string' ? ev.sess.browserName : '',
863
+ device: typeof ev.sess.device === 'string' ? ev.sess.device : '',
864
+ }
808
865
  if (s.expiresAt > Date.now()) this.live.set(ev.token, s)
809
866
  } else if (ev.op === 'remove') {
810
867
  this.live.delete(ev.token)
@@ -892,7 +949,15 @@ class PersistentDevices {
892
949
  try { ev = JSON.parse(line) } catch (e) { continue }
893
950
  if (!ev || typeof ev.op !== 'string') continue
894
951
  if (ev.op === 'add' && ev.dev && typeof ev.dev === 'object' && typeof ev.dev.id === 'string') {
895
- this.live.set(ev.dev.id, { ...ev.dev })
952
+ this.live.set(ev.dev.id, {
953
+ ...ev.dev,
954
+ description: typeof ev.dev.description === 'string' ? ev.dev.description : '',
955
+ lastIp: typeof ev.dev.lastIp === 'string' ? ev.dev.lastIp : null,
956
+ lastUa: typeof ev.dev.lastUa === 'string' ? ev.dev.lastUa : null,
957
+ os: typeof ev.dev.os === 'string' ? ev.dev.os : '',
958
+ browser: typeof ev.dev.browser === 'string' ? ev.dev.browser : '',
959
+ device: typeof ev.dev.device === 'string' ? ev.dev.device : '',
960
+ })
896
961
  } else if (ev.op === 'revoke' && typeof ev.id === 'string') {
897
962
  const d = this.live.get(ev.id)
898
963
  if (d) d.revokedAt = Number(ev.at) || Date.now()
@@ -925,17 +990,23 @@ class PersistentDevices {
925
990
  } catch (e) { /* 压缩失败不影响运行 */ }
926
991
  }
927
992
 
928
- add({ name, hash, clientId, scope }) {
993
+ add({ name, description, hash, clientId, scope }) {
929
994
  const id = randomBytes(8).toString('hex')
930
995
  const now = Date.now()
931
996
  const rec = {
932
997
  id,
933
998
  name: String(name || '设备').slice(0, 60),
999
+ description: String(description || '').slice(0, 160),
934
1000
  hash,
935
1001
  clientId: String(clientId || ''),
936
1002
  scope: String(scope || 'dsh'),
937
1003
  createdAt: now,
938
1004
  lastUsedAt: 0,
1005
+ lastIp: null,
1006
+ lastUa: null,
1007
+ os: '',
1008
+ browser: '',
1009
+ device: '',
939
1010
  }
940
1011
  this.live.set(id, rec)
941
1012
  this.append({ op: 'add', dev: rec })
@@ -955,13 +1026,13 @@ class PersistentDevices {
955
1026
  return null
956
1027
  }
957
1028
 
958
- touch(dev) {
1029
+ touch(dev, force) {
959
1030
  const now = Date.now()
960
1031
  dev.lastUsedAt = now
961
- // 每次请求都写盘会放大 IO:最多每分钟落一次 touch
962
- if (now - (dev.persistedAt || 0) >= 60000) {
1032
+ // 每次请求都写盘会放大 IO:最多每分钟落一次;UA/IP 变化时立即落。
1033
+ if (force || now - (dev.persistedAt || 0) >= 60000) {
963
1034
  dev.persistedAt = now
964
- this.append({ op: 'touch', id: dev.id, at: now })
1035
+ this.append({ op: 'add', dev })
965
1036
  }
966
1037
  }
967
1038
 
@@ -980,10 +1051,17 @@ class PersistentDevices {
980
1051
  out.push({
981
1052
  id: dev.id,
982
1053
  name: dev.name,
1054
+ description: dev.description || '',
983
1055
  clientId: dev.clientId,
984
1056
  scope: dev.scope,
985
1057
  createdAt: dev.createdAt,
986
1058
  lastUsedAt: dev.lastUsedAt,
1059
+ lastIp: dev.lastIp || null,
1060
+ lastUa: dev.lastUa || null,
1061
+ os: dev.os || '',
1062
+ browser: dev.browser || '',
1063
+ device: dev.device || '',
1064
+ fingerprint: sessionFingerprint({ ua: dev.lastUa, ip: dev.lastIp }),
987
1065
  })
988
1066
  }
989
1067
  out.sort((a, b) => b.createdAt - a.createdAt)
@@ -997,6 +1075,84 @@ class PersistentDevices {
997
1075
  }
998
1076
  }
999
1077
 
1078
+ /**
1079
+ * DeepSeek 登录回调中转窗口(独立子窗口,类似 SSO 验证弹窗)。
1080
+ * 用户在主窗口点 DeepSeek 登录时由插件打开;授权后把回环回调 URL 粘进来。
1081
+ */
1082
+ function relayWindowPage() {
1083
+ return `<!DOCTYPE html>
1084
+ <html lang="zh-CN">
1085
+ <head>
1086
+ <meta charset="utf-8">
1087
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1088
+ <meta name="color-scheme" content="light dark">
1089
+ <title>DeepSeek 登录 · 等待验证</title>
1090
+ <style>
1091
+ :root { color-scheme: light dark; }
1092
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
1093
+ background: #f6f7f9; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: #15171b; }
1094
+ @media (prefers-color-scheme: dark) { body { background: #141416; color: #f2f3f5; } }
1095
+ .card { width: 400px; max-width: calc(100vw - 40px); padding: 26px 24px; box-sizing: border-box;
1096
+ background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; box-shadow: 0 18px 50px rgba(0,0,0,.10); }
1097
+ @media (prefers-color-scheme: dark) { .card { background: #1e1e21; border-color: rgba(255,255,255,.10); box-shadow: 0 18px 50px rgba(0,0,0,.5); } }
1098
+ h1 { margin: 0 0 6px; font-size: 17px; }
1099
+ p.sub { margin: 0 0 14px; font-size: 13px; line-height: 1.65; opacity: .68; }
1100
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4a7cf7; margin-right: 7px;
1101
+ animation: pulse 1.2s ease-in-out infinite; }
1102
+ @keyframes pulse { 0%,100% { opacity: .35 } 50% { opacity: 1 } }
1103
+ input { box-sizing: border-box; width: 100%; height: 38px; padding: 0 11px; font-size: 12.5px; border-radius: 9px;
1104
+ border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; outline: none; }
1105
+ input:focus { border-color: #4a7cf7; box-shadow: 0 0 0 3px rgba(74,124,247,.18); }
1106
+ button { width: 100%; height: 40px; margin-top: 12px; font-size: 13px; font-weight: 600; border-radius: 9px; cursor: pointer;
1107
+ border: 1px solid #4a7cf7; background: #4a7cf7; color: #fff; }
1108
+ button:disabled { opacity: .5; cursor: default; }
1109
+ #m { margin: 12px 0 0; font-size: 12.5px; line-height: 1.6; min-height: 18px; }
1110
+ </style>
1111
+ </head>
1112
+ <body>
1113
+ <div class="card">
1114
+ <h1><span class="dot"></span>等待验证</h1>
1115
+ <p class="sub">请在另一个窗口完成 DeepSeek 授权。授权后浏览器会跳到 <code>127.0.0.1</code> 的回调地址(远程通常打不开),把地址栏里的完整 URL 粘贴到这里。</p>
1116
+ <input id="u" placeholder="http://127.0.0.1:3080/oauth/callback?code=…&state=…" autocomplete="off" spellcheck="false">
1117
+ <button id="b">完成登录</button>
1118
+ <p id="m"></p>
1119
+ </div>
1120
+ <script>
1121
+ (function () {
1122
+ var u = document.getElementById('u');
1123
+ var b = document.getElementById('b');
1124
+ var m = document.getElementById('m');
1125
+ b.addEventListener('click', async function () {
1126
+ var url = u.value.trim();
1127
+ if (!url) { m.textContent = '请粘贴完整回调地址'; return; }
1128
+ b.disabled = true;
1129
+ m.textContent = '正在完成登录…';
1130
+ try {
1131
+ var res = await fetch('/dsh-webui-oauth/oauth-relay', {
1132
+ method: 'POST', headers: { 'content-type': 'application/json' },
1133
+ body: JSON.stringify({ url: url })
1134
+ });
1135
+ if (res.status === 401) { m.textContent = '会话已失效,请关闭本窗口并在主页面重新登录'; return; }
1136
+ var j = await res.json();
1137
+ if (j && j.ok) {
1138
+ m.textContent = '已收到回调,正在完成登录…';
1139
+ try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-relayed', ok: true }, location.origin); } catch (e) {}
1140
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 700);
1141
+ } else {
1142
+ m.textContent = (j && (j.hint || j.reason || j.error)) || '中转失败';
1143
+ }
1144
+ } catch (e) {
1145
+ m.textContent = '无法连接认证服务';
1146
+ } finally {
1147
+ b.disabled = false;
1148
+ }
1149
+ });
1150
+ })();
1151
+ </script>
1152
+ </body>
1153
+ </html>`
1154
+ }
1155
+
1000
1156
  /**
1001
1157
  * 设备授权批准页(RFC 8628 的 verification_uri)。
1002
1158
  * 公开 GET;真正的批准 POST 需要浏览器会话(自管端点里 checkRequest)。
@@ -1053,18 +1209,41 @@ function deviceApprovalPage(userCode) {
1053
1209
  var m = document.getElementById('m');
1054
1210
  var ok = document.getElementById('ok');
1055
1211
  var no = document.getElementById('no');
1212
+ function goLogin() {
1213
+ var next = location.pathname + location.search;
1214
+ location.href = '/dsh-webui-oauth/login?next=' + encodeURIComponent(next);
1215
+ }
1216
+ // 未登录先送去登录页;登录成功(密码或 SSO)后由 next 回到本页并带上 user_code。
1217
+ fetch('/dsh-webui-oauth/status').then(function (r) {
1218
+ if (r.status === 401) goLogin();
1219
+ }).catch(function () {});
1056
1220
  async function act(decision) {
1057
1221
  ok.disabled = true; no.disabled = true;
1058
1222
  m.textContent = '处理中…';
1059
1223
  try {
1224
+ // 批准前先看应用声明:让用户知道自己在批准哪个应用。
1225
+ if (decision === 'approve') {
1226
+ try {
1227
+ var infoRes = await fetch('/dsh-webui-oauth/device/inspect', {
1228
+ method: 'POST', headers: { 'content-type': 'application/json' },
1229
+ body: JSON.stringify({ user_code: document.getElementById('c').value })
1230
+ });
1231
+ if (infoRes.status === 401) { goLogin(); return; }
1232
+ var info = await infoRes.json();
1233
+ if (info && info.ok) {
1234
+ var label = info.app + (info.description ? '(' + info.description + ')' : '');
1235
+ if (!window.confirm('应用「' + label + '」请求访问此 DSH,是否批准?')) {
1236
+ m.textContent = '已取消';
1237
+ return;
1238
+ }
1239
+ }
1240
+ } catch (e) { /* inspect 失败不阻断批准 */ }
1241
+ }
1060
1242
  var res = await fetch('/dsh-webui-oauth/device/approve', {
1061
1243
  method: 'POST', headers: { 'content-type': 'application/json' },
1062
1244
  body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
1063
1245
  });
1064
- if (res.status === 401) {
1065
- m.innerHTML = '请先登录后再批准。<a href="/dsh-webui-oauth/login">前往登录</a>';
1066
- return;
1067
- }
1246
+ if (res.status === 401) { goLogin(); return; }
1068
1247
  var j = await res.json();
1069
1248
  if (j && j.ok) m.textContent = decision === 'approve' ? '已批准,请返回应用完成登录。' : '已拒绝该设备。';
1070
1249
  else m.textContent = (j && (j.reason || j.error)) || '操作失败';
@@ -1199,6 +1378,8 @@ const LOGIN_PAGE = `<!DOCTYPE html>
1199
1378
  var MODE = "__MODE__";
1200
1379
  var OIDC_ENABLED = "__OIDC_ENABLED__";
1201
1380
  var SSO_ONLY = "__SSO_ONLY__";
1381
+ var NEXT = '';
1382
+ try { NEXT = new URLSearchParams(location.search).get('next') || ''; } catch (err) { NEXT = ''; }
1202
1383
  var sub = document.getElementById('sub'), pl = document.getElementById('pl'), pc = document.getElementById('pc'), e = document.getElementById('e'),
1203
1384
  u = document.getElementById('u'), p = document.getElementById('p'), p2 = document.getElementById('p2'), b = document.getElementById('b'),
1204
1385
  ul = document.getElementById('ul'),
@@ -1237,7 +1418,10 @@ if (SSO_ONLY === 'true' && MODE === 'login') {
1237
1418
  if (sso) {
1238
1419
  sso.addEventListener('click', function () {
1239
1420
  var base = location.origin || '';
1240
- var target = '/dsh-webui-oauth/oidc/login' + (base ? '?base=' + encodeURIComponent(base) : '');
1421
+ var target = '/dsh-webui-oauth/oidc/login';
1422
+ var sep = '?';
1423
+ if (base) { target += '?base=' + encodeURIComponent(base); sep = '&'; }
1424
+ if (NEXT) target += sep + 'next=' + encodeURIComponent(NEXT);
1241
1425
  location.href = target;
1242
1426
  });
1243
1427
  }
@@ -1269,6 +1453,7 @@ f.addEventListener('submit', function (ev) {
1269
1453
  }
1270
1454
  b.disabled = true; b.textContent = '请稍候…';
1271
1455
  var body = { username: username, password: password };
1456
+ if (NEXT) body.next = NEXT;
1272
1457
  if (MODE === 'setup') body.token = token;
1273
1458
  fetch(MODE === 'setup' ? '/dsh-webui-oauth/setup' : '/dsh-webui-oauth/login', {
1274
1459
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body)
@@ -1337,6 +1522,7 @@ const PUBLIC_EXACT_PATHS = new Set([
1337
1522
  '/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
1338
1523
  '/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
1339
1524
  '/dsh-webui-oauth/device', // 设备授权批准页(GET;批准 POST 走自管端点)
1525
+ '/dsh-webui-oauth/relay-window', // DeepSeek 登录回调中转子窗口
1340
1526
  '/dsh-webui-oauth/device/authorize', // RFC 8628 设备授权请求
1341
1527
  '/dsh-webui-oauth/device/token', // RFC 8628 轮询换 token
1342
1528
  ])
@@ -1362,7 +1548,10 @@ const SELF_GATED_EXACT_PATHS = new Set([
1362
1548
  '/dsh-webui-oauth/oauth-relay',
1363
1549
  '/dsh-webui-oauth/remote-trust',
1364
1550
  '/dsh-webui-oauth/sessions/revoke',
1551
+ '/dsh-webui-oauth/sessions/revoke-one',
1552
+ '/dsh-webui-oauth/sessions',
1365
1553
  '/dsh-webui-oauth/device/approve',
1554
+ '/dsh-webui-oauth/device/inspect',
1366
1555
  '/dsh-webui-oauth/device/core-session',
1367
1556
  '/dsh-webui-oauth/devices',
1368
1557
  '/dsh-webui-oauth/devices/revoke',
@@ -2406,6 +2595,20 @@ const REAUTH_POPUP_HTML = `<!doctype html>
2406
2595
  try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-reauth', ok: true }, location.origin); } catch (e) {}
2407
2596
  setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
2408
2597
  </script></body></html>`
2598
+
2599
+ // OIDC 绑定/换绑的弹窗回调页:通知 opener 后自我关闭。
2600
+ const BIND_POPUP_HTML = `<!doctype html>
2601
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>绑定成功</title>
2602
+ <style>
2603
+ html,body{height:100%;margin:0}
2604
+ body{display:flex;align-items:center;justify-content:center;font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;background:#0b0b0c;color:#e8e8e8}
2605
+ .box{text-align:center;opacity:.92}
2606
+ </style></head>
2607
+ <body><div class="box"><p>绑定成功,正在返回设置页…</p><p style="font-size:12px;opacity:.6">如果窗口没有自动关闭,请手动关闭。</p></div>
2608
+ <script>
2609
+ try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-bound', ok: true }, location.origin); } catch (e) {}
2610
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
2611
+ </script></body></html>`
2409
2612
  // 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
2410
2613
  // 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
2411
2614
  const OIDC_STATE_TTL_MS = 10 * 60 * 1000
@@ -2417,6 +2620,25 @@ const OIDC_MAX_PENDING_STATES = 1000
2417
2620
  // 纯 origin 格式校验:http/https、无 path/query/hash、无 userinfo。
2418
2621
  // 刻意**不做** host 匹配——trustBrowserOrigin 语义即"信前端 origin",
2419
2622
  // 这里只挡"明显构造的垃圾值/注入",保底格式合法性。
2623
+ /**
2624
+ * 校验登录后回跳路径:只允许同源相对路径。
2625
+ * 拒绝 "//host"、反斜杠、绝对 URL 与协议相对 URL(防开放重定向)。
2626
+ */
2627
+ export function safeNextPath(raw) {
2628
+ if (typeof raw !== 'string') return null
2629
+ const value = raw.trim()
2630
+ if (!value || value.length > 2048) return null
2631
+ if (value[0] !== '/' || value[1] === '/') return null
2632
+ if (value.includes('\\')) return null
2633
+ try {
2634
+ const u = new URL(value, 'http://dsh.invalid')
2635
+ if (u.origin !== 'http://dsh.invalid') return null
2636
+ return u.pathname + u.search + u.hash
2637
+ } catch (e) {
2638
+ return null
2639
+ }
2640
+ }
2641
+
2420
2642
  export function isValidOrigin(raw) {
2421
2643
  if (typeof raw !== 'string' || !raw.trim()) return false
2422
2644
  // 拒绝控制字符(\r \n \t 等):WHATWG URL 解析器会静默剥离 CRLF 产生看似合法的畸形 URL,
@@ -2956,7 +3178,16 @@ export async function apply(ctx) {
2956
3178
  if (bearer) {
2957
3179
  const dev = devices.findByToken(bearer)
2958
3180
  if (!dev) return false
2959
- devices.touch(dev)
3181
+ // 设备会话同样以当前 HTTP 请求实时刷新来源信息(IP / UA / 系统 / 浏览器)。
3182
+ const meta = requestMeta(req)
3183
+ const parsed = describeUserAgent(meta.ua)
3184
+ const changed = dev.lastIp !== meta.ip || dev.lastUa !== meta.ua
3185
+ dev.lastIp = meta.ip
3186
+ dev.lastUa = meta.ua
3187
+ dev.os = parsed.os
3188
+ dev.browser = parsed.browser
3189
+ dev.device = parsed.device
3190
+ devices.touch(dev, changed)
2960
3191
  return true
2961
3192
  }
2962
3193
  const token = cookieOf(req, COOKIE_NAME)
@@ -2967,7 +3198,23 @@ export async function apply(ctx) {
2967
3198
  sessions.delete(token)
2968
3199
  return false
2969
3200
  }
2970
- if (s.browser === true) s.expiresAt = Date.now() + SESSION_TTL_MS
3201
+ // 浏览器标识以**当前 HTTP 请求**为准,实时刷新(UA / IP / 系统 / 浏览器 / 设备)。
3202
+ // 落盘做节流:UA/IP 变化立即写,否则每分钟一次,避免每个静态资源请求都写盘。
3203
+ const now = Date.now()
3204
+ const meta = requestMeta(req)
3205
+ const parsed = describeUserAgent(meta.ua)
3206
+ const changed = s.ip !== meta.ip || s.ua !== meta.ua
3207
+ s.lastSeenAt = now
3208
+ s.ip = meta.ip
3209
+ s.ua = meta.ua
3210
+ s.os = parsed.os
3211
+ s.browserName = parsed.browser
3212
+ s.device = parsed.device
3213
+ if (changed || now - (s.persistedAt || 0) > 60000) {
3214
+ s.persistedAt = now
3215
+ sessions.append({ op: 'add', token, sess: s })
3216
+ }
3217
+ if (s.browser === true) s.expiresAt = now + SESSION_TTL_MS
2971
3218
  return true
2972
3219
  }
2973
3220
 
@@ -2998,10 +3245,24 @@ export async function apply(ctx) {
2998
3245
  if (s) delete s.reauthAt
2999
3246
  }
3000
3247
 
3001
- function createSession(username, ttl) {
3248
+ function createSession(username, ttl, req) {
3002
3249
  const token = randomBytes(24).toString('hex')
3003
3250
  const ttlMs = ttl > 0 ? ttl * 3600 * 1000 : SESSION_TTL_MS
3004
- sessions.set(token, { username, expiresAt: Date.now() + ttlMs, browser: ttl <= 0 })
3251
+ const meta = requestMeta(req || {})
3252
+ const parsed = describeUserAgent(meta.ua)
3253
+ const now = Date.now()
3254
+ sessions.set(token, {
3255
+ username,
3256
+ expiresAt: now + ttlMs,
3257
+ browser: ttl <= 0,
3258
+ createdAt: now,
3259
+ lastSeenAt: now,
3260
+ ip: meta.ip,
3261
+ ua: meta.ua,
3262
+ os: parsed.os,
3263
+ browserName: parsed.browser,
3264
+ device: parsed.device,
3265
+ })
3005
3266
  return { token, maxAge: ttl > 0 ? ttl * 3600 : undefined }
3006
3267
  }
3007
3268
 
@@ -3212,6 +3473,8 @@ export async function apply(ctx) {
3212
3473
  handler: async (req, res) => {
3213
3474
  try {
3214
3475
  if (req.method === 'GET' || req.method === 'HEAD') {
3476
+ let next = null
3477
+ try { next = safeNextPath(new URL(req.url || '/', 'http://x').searchParams.get('next')) } catch (e) { next = null }
3215
3478
  const credsNow = await readCredentials(ctx)
3216
3479
  // 已有有效会话时,登录页不该再显示表单——直接送回首页。
3217
3480
  //
@@ -3222,8 +3485,10 @@ export async function apply(ctx) {
3222
3485
  // 只在认证已启用时判断:首次初始化(enabledFlag=false)时 checkRequest
3223
3486
  // 恒为 true,不能把首次访问的人也送回首页(那会 404/白屏)。
3224
3487
  if (enabledFlag && checkRequest(req)) {
3225
- let target = '/'
3226
- try { target = postLoginRedirect(ctx, req) || '/' } catch (e) { target = '/' }
3488
+ let target = next
3489
+ if (!target) {
3490
+ try { target = postLoginRedirect(ctx, req) || '/' } catch (e) { target = '/' }
3491
+ }
3227
3492
  res.writeHead(302, { location: target, 'cache-control': 'no-store' })
3228
3493
  res.end()
3229
3494
  return
@@ -3231,7 +3496,9 @@ export async function apply(ctx) {
3231
3496
  // 「仅允许 SSO」:密码表单没有意义,直接送去 SSO 入口。
3232
3497
  // 放在会话判断之后:已登录仍然优先回首页。
3233
3498
  if (enabledFlag && ssoOnlyActiveFlag) {
3234
- res.writeHead(302, { location: loginLocation(req), 'cache-control': 'no-store' })
3499
+ let location = loginLocation(req)
3500
+ if (next) location += (location.includes('?') ? '&' : '?') + 'next=' + encodeURIComponent(next)
3501
+ res.writeHead(302, { location, 'cache-control': 'no-store' })
3235
3502
  res.end()
3236
3503
  return
3237
3504
  }
@@ -3304,10 +3571,11 @@ export async function apply(ctx) {
3304
3571
  sendJson(res, 200, { ok: false, error: 'invalid' })
3305
3572
  return
3306
3573
  }
3307
- const s = createSession(username, ttlOf(creds))
3574
+ const s = createSession(username, ttlOf(creds), req)
3308
3575
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
3309
3576
  await auditLog(ctx, 'login_success', { username, ip: meta.ip, ua: meta.ua })
3310
- sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
3577
+ const next = safeNextPath(body.next)
3578
+ sendJson(res, 200, { ok: true, redirect: next || postLoginRedirect(ctx, req) })
3311
3579
  return
3312
3580
  }
3313
3581
  sendJson(res, 405, { error: '仅支持 GET/POST' })
@@ -3365,7 +3633,7 @@ export async function apply(ctx) {
3365
3633
  await writeCredentials(ctx, { v: 3, username, hash: await hashPassword(password), ttl: TTL_DEFAULT })
3366
3634
  try { unlinkSync(DATA_DIR + '/setup-token') } catch (e) { /* already gone */ }
3367
3635
  enabledFlag = true
3368
- const s = createSession(username, TTL_DEFAULT)
3636
+ const s = createSession(username, TTL_DEFAULT, req)
3369
3637
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
3370
3638
  await auditLog(ctx, 'setup_success', { username, ip: meta.ip, ua: meta.ua })
3371
3639
  sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
@@ -3444,6 +3712,105 @@ export async function apply(ctx) {
3444
3712
  },
3445
3713
  }), 'dsh-webui-oauth: revoke sessions')
3446
3714
 
3715
+ // 浏览器会话列表:GET /dsh-webui-oauth/sessions
3716
+ ctx.effect(() => ctx.webServer.register({
3717
+ kind: 'exact',
3718
+ path: '/dsh-webui-oauth/sessions',
3719
+ handler: async (req, res) => {
3720
+ try {
3721
+ if (req.method !== 'GET') {
3722
+ sendJson(res, 405, { error: '仅支持 GET' })
3723
+ return
3724
+ }
3725
+ if (enabledFlag && !sessionOf(req)) {
3726
+ sendJson(res, 401, { error: 'unauthorized' })
3727
+ return
3728
+ }
3729
+ const currentToken = cookieOf(req, COOKIE_NAME)
3730
+ const now = Date.now()
3731
+ const out = []
3732
+ for (const [token, s] of sessions.live) {
3733
+ if (s.expiresAt <= now) continue
3734
+ out.push({
3735
+ id: sessionShortId(token),
3736
+ current: token === currentToken,
3737
+ createdAt: s.createdAt || null,
3738
+ lastSeenAt: s.lastSeenAt || s.createdAt || null,
3739
+ expiresAt: s.expiresAt,
3740
+ os: s.os || '',
3741
+ browser: s.browserName || '',
3742
+ device: s.device || '',
3743
+ ip: s.ip || null,
3744
+ fingerprint: sessionFingerprint(s),
3745
+ })
3746
+ }
3747
+ out.sort((a, b) => (b.current ? 1 : 0) - (a.current ? 1 : 0) || (b.lastSeenAt || 0) - (a.lastSeenAt || 0))
3748
+ sendJson(res, 200, { ok: true, sessions: out })
3749
+ } catch (e) {
3750
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3751
+ }
3752
+ },
3753
+ }), 'dsh-webui-oauth: sessions list')
3754
+
3755
+ // 吊销指定浏览器会话:POST /dsh-webui-oauth/sessions/revoke-one
3756
+ ctx.effect(() => ctx.webServer.register({
3757
+ kind: 'exact',
3758
+ path: '/dsh-webui-oauth/sessions/revoke-one',
3759
+ handler: async (req, res) => {
3760
+ try {
3761
+ if (req.method !== 'POST') {
3762
+ sendJson(res, 405, { error: '仅支持 POST' })
3763
+ return
3764
+ }
3765
+ if (enabledFlag && !sessionOf(req)) {
3766
+ sendJson(res, 401, { error: 'unauthorized' })
3767
+ return
3768
+ }
3769
+ const body = await readJsonBody(req, res)
3770
+ if (body === null) return
3771
+ const id = String(body.id || '')
3772
+ const currentToken = cookieOf(req, COOKIE_NAME)
3773
+ let target = null
3774
+ for (const token of sessions.live.keys()) {
3775
+ if (sessionShortId(token) === id) { target = token; break }
3776
+ }
3777
+ if (!target) {
3778
+ sendJson(res, 200, { ok: false, error: 'not-found' })
3779
+ return
3780
+ }
3781
+ if (target === currentToken) {
3782
+ sendJson(res, 200, { ok: false, error: 'current-session', reason: '当前会话请用「退出登录」' })
3783
+ return
3784
+ }
3785
+ sessions.delete(target)
3786
+ const meta = requestMeta(req)
3787
+ await auditLog(ctx, 'session_revoked', { username: null, ip: meta.ip, ua: meta.ua, detail: id })
3788
+ sendJson(res, 200, { ok: true })
3789
+ } catch (e) {
3790
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3791
+ }
3792
+ },
3793
+ }), 'dsh-webui-oauth: revoke one session')
3794
+
3795
+ // DeepSeek 登录回调中转子窗口
3796
+ ctx.effect(() => ctx.webServer.register({
3797
+ kind: 'exact',
3798
+ path: '/dsh-webui-oauth/relay-window',
3799
+ handler: async (req, res) => {
3800
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
3801
+ sendJson(res, 405, { error: '仅支持 GET' })
3802
+ return
3803
+ }
3804
+ res.writeHead(200, {
3805
+ 'content-type': 'text/html; charset=utf-8',
3806
+ 'cache-control': 'no-store',
3807
+ 'x-content-type-options': 'nosniff',
3808
+ 'x-frame-options': 'DENY',
3809
+ })
3810
+ res.end(relayWindowPage())
3811
+ },
3812
+ }), 'dsh-webui-oauth: relay window')
3813
+
3447
3814
  // ---------------- 设备授权(RFC 8628 device_code) ----------------
3448
3815
  //
3449
3816
  // 面向 App / CLI:无回调、无浏览器 Cookie。流程:
@@ -3477,15 +3844,25 @@ export async function apply(ctx) {
3477
3844
  sendJson(res, 429, { error: 'temporarily_unavailable' })
3478
3845
  return
3479
3846
  }
3847
+ // 设备授权必须声明"是什么应用":名称进入待批准记录与设备列表。
3848
+ const name = String(body.client_name || body.app_name || '').trim().slice(0, 60)
3849
+ if (!name) {
3850
+ sendJson(res, 400, {
3851
+ error: 'invalid_request',
3852
+ error_description: 'client_name 必填:设备授权需要在申请时声明应用名称',
3853
+ })
3854
+ return
3855
+ }
3856
+ const description = String(body.client_description || body.app_description || '').trim().slice(0, 160)
3480
3857
  const deviceCode = randomBytes(32).toString('hex')
3481
3858
  const userCode = makeUserCode()
3482
3859
  const scope = String(body.scope || 'dsh').slice(0, 80)
3483
- const name = String(body.client_name || body.name || '').trim().slice(0, 60) || 'App'
3484
3860
  pendingDevices.set(deviceCode, {
3485
3861
  userCode,
3486
3862
  clientId,
3487
3863
  scope,
3488
3864
  name,
3865
+ description,
3489
3866
  expiresAt: now + DEVICE_CODE_TTL_MS,
3490
3867
  interval: DEVICE_INTERVAL_S,
3491
3868
  lastPollAt: 0,
@@ -3555,7 +3932,13 @@ export async function apply(ctx) {
3555
3932
  // 单次发放:同一个 device_code 只能换一次 token。
3556
3933
  const token = randomBytes(32).toString('hex')
3557
3934
  const hash = createHash('sha256').update(token).digest('hex')
3558
- const rec = devices.add({ name: pending.name, hash, clientId: pending.clientId, scope: pending.scope })
3935
+ const rec = devices.add({
3936
+ name: pending.name,
3937
+ description: pending.description,
3938
+ hash,
3939
+ clientId: pending.clientId,
3940
+ scope: pending.scope,
3941
+ })
3559
3942
  pending.issued = true
3560
3943
  pendingDevices.delete(code)
3561
3944
  const meta = requestMeta(req)
@@ -3649,6 +4032,48 @@ export async function apply(ctx) {
3649
4032
  },
3650
4033
  }), 'dsh-webui-oauth: device approve')
3651
4034
 
4035
+ // 批准前查看待授权应用的声明(仅浏览器会话)
4036
+ ctx.effect(() => ctx.webServer.register({
4037
+ kind: 'exact',
4038
+ path: '/dsh-webui-oauth/device/inspect',
4039
+ handler: async (req, res) => {
4040
+ try {
4041
+ if (req.method !== 'POST') {
4042
+ sendJson(res, 405, { error: '仅支持 POST' })
4043
+ return
4044
+ }
4045
+ if (enabledFlag && !sessionOf(req)) {
4046
+ sendJson(res, 401, { error: 'unauthorized' })
4047
+ return
4048
+ }
4049
+ const body = await readJsonBody(req, res)
4050
+ if (body === null) return
4051
+ const wanted = normalizeUserCode(body.user_code)
4052
+ let entry = null
4053
+ for (const pending of pendingDevices.values()) {
4054
+ if (pending.userCode.replace('-', '') === wanted) { entry = pending; break }
4055
+ }
4056
+ if (!entry) {
4057
+ sendJson(res, 200, { ok: false, error: 'invalid-code' })
4058
+ return
4059
+ }
4060
+ if (entry.expiresAt <= Date.now()) {
4061
+ sendJson(res, 200, { ok: false, error: 'expired' })
4062
+ return
4063
+ }
4064
+ sendJson(res, 200, {
4065
+ ok: true,
4066
+ app: entry.name,
4067
+ description: entry.description || '',
4068
+ client_id: entry.clientId,
4069
+ scope: entry.scope,
4070
+ })
4071
+ } catch (e) {
4072
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
4073
+ }
4074
+ },
4075
+ }), 'dsh-webui-oauth: device inspect')
4076
+
3652
4077
  // 设备会话列表 / 吊销(浏览器会话)
3653
4078
  ctx.effect(() => ctx.webServer.register({
3654
4079
  kind: 'exact',
@@ -4066,11 +4491,21 @@ export async function apply(ctx) {
4066
4491
  // 不必等后台定时器(否则用户保存后要最多 60 秒才看到效果)。
4067
4492
  await refreshSsoOnly(credsOut)
4068
4493
  enabledFlag = true
4494
+ let revoked = 0
4069
4495
  if (wasEnabled) {
4070
- const keepToken = cookieOf(req, COOKIE_NAME)
4071
- destroyAllSessionsExcept(keepToken)
4496
+ // 只有**改密码**才需要吊销其它浏览器会话;只改 TTL/OIDC 配置不应该
4497
+ // 把人从别的浏览器里踢出去。设备会话始终不受影响(它们由设备列表单独吊销)。
4498
+ if (!keepPassword) {
4499
+ const keepToken = cookieOf(req, COOKIE_NAME)
4500
+ let before = 0
4501
+ for (const _ of sessions.live) before += 1
4502
+ destroyAllSessionsExcept(keepToken)
4503
+ let after = 0
4504
+ for (const _ of sessions.live) after += 1
4505
+ revoked = Math.max(0, before - after)
4506
+ }
4072
4507
  } else {
4073
- const s = createSession(username, ttl)
4508
+ const s = createSession(username, ttl, req)
4074
4509
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
4075
4510
  }
4076
4511
  const detailParts = []
@@ -4078,7 +4513,7 @@ export async function apply(ctx) {
4078
4513
  if (body.ttl !== undefined) detailParts.push('有效期已修改')
4079
4514
  await auditLog(ctx, 'configure_success', { username, ip: meta.ip, ua: meta.ua, detail: detailParts.length ? detailParts.join(',') : null })
4080
4515
  clearSessionReauth(req)
4081
- sendJson(res, 200, { ok: true })
4516
+ sendJson(res, 200, { ok: true, revoked, passwordChanged: !keepPassword })
4082
4517
  } catch (e) {
4083
4518
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
4084
4519
  }
@@ -4200,9 +4635,11 @@ export async function apply(ctx) {
4200
4635
  // 的小页面,而不是把整个应用加载进弹窗。
4201
4636
  let popup = false
4202
4637
  try {
4203
- popup = purpose === 'reauth' && new URL(req.url || '/', 'http://x').searchParams.get('popup') === '1'
4638
+ popup = new URL(req.url || '/', 'http://x').searchParams.get('popup') === '1'
4204
4639
  } catch (e) { popup = false }
4205
- oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken, popup })
4640
+ let next = null
4641
+ try { next = safeNextPath(new URL(req.url || '/', 'http://x').searchParams.get('next')) } catch (e) { next = null }
4642
+ oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken, popup, next })
4206
4643
  const authUrl = new URL(disc.metadata.authorization_endpoint)
4207
4644
  authUrl.searchParams.set('response_type', 'code')
4208
4645
  authUrl.searchParams.set('client_id', o.clientId)
@@ -4527,7 +4964,17 @@ export async function apply(ctx) {
4527
4964
  // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
4528
4965
  await writeCredentials(ctx, { ...creds, boundSub: rawSub })
4529
4966
  await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
4530
- // 成功必须【把浏览器送回应用】,而不是停在 JSON 上(见 redirectBackToApp 说明)。
4967
+ // 弹窗发起:回一个自我关闭的页面;否则把浏览器送回应用。
4968
+ if (st.popup) {
4969
+ res.writeHead(200, {
4970
+ 'content-type': 'text/html; charset=utf-8',
4971
+ 'cache-control': 'no-store',
4972
+ 'x-content-type-options': 'nosniff',
4973
+ 'set-cookie': clearStateCookie['Set-Cookie'],
4974
+ })
4975
+ res.end(BIND_POPUP_HTML)
4976
+ return
4977
+ }
4531
4978
  redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
4532
4979
  return
4533
4980
  }
@@ -4549,7 +4996,7 @@ export async function apply(ctx) {
4549
4996
 
4550
4997
  // 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
4551
4998
  // 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
4552
- const s = createSession(rawSub, ttlOf(creds))
4999
+ const s = createSession(rawSub, ttlOf(creds), req)
4553
5000
  await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
4554
5001
  // 授权往返结束:直接 302 回应用,并同时下发会话 Cookie、清除一次性 state Cookie。
4555
5002
  //
@@ -4558,7 +5005,8 @@ export async function apply(ctx) {
4558
5005
  // 被 302 到 IdP 再 302 回来的),渲染 JSON 就停在死页面上了。
4559
5006
  // Set-Cookie 必须一次带两条(会话 + 清除 state),缺任一条都会出问题。
4560
5007
  res.writeHead(302, {
4561
- location: postLoginRedirect(ctx, req),
5008
+ // 有 next(例如设备批准页)就回原页面;否则回应用根并带核心 launch token。
5009
+ location: st.next || postLoginRedirect(ctx, req),
4562
5010
  'Set-Cookie': [sessionCookie(s.token, s.maxAge), clearStateCookie['Set-Cookie']],
4563
5011
  })
4564
5012
  res.end()
package/lib/client.js CHANGED
@@ -68,6 +68,33 @@ window.__ModuleLoader__.load({
68
68
  if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
69
69
  }, 'dsh-webui-oauth: styles');
70
70
 
71
+ // ---------------- DeepSeek 登录:打开回调中转子窗口 ----------------
72
+ // 官方登录会新开窗口去 IdP;远程部署回调落在 127.0.0.1,打不开。
73
+ // 在用户点击「登录」的同一手势里打开中转窗口(避免被弹窗拦截),
74
+ // 用户授权后把回调 URL 粘进去即可。命中失败时仍有内联粘贴框兜底。
75
+ (function hookDeepSeekLogin() {
76
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return;
77
+ let last = 0;
78
+ const handler = (ev) => {
79
+ try {
80
+ const target = ev.target;
81
+ const btn = target && target.closest ? target.closest('button,[role="button"]') : null;
82
+ if (!btn) return;
83
+ const label = (btn.textContent || '').trim();
84
+ if (!/^(登录|Sign in|继续登录|重新登录)$/i.test(label)) return;
85
+ const scope = btn.closest('[role="dialog"],section,form,div');
86
+ const text = scope ? (scope.textContent || '') : '';
87
+ if (!/DeepSeek|账号|Account/i.test(text)) return;
88
+ const now = Date.now();
89
+ if (now - last < 1500) return;
90
+ last = now;
91
+ window.open('/dsh-webui-oauth/relay-window', 'dsh-wua-relay', 'width=480,height=620');
92
+ } catch (e) { /* best effort */ }
93
+ };
94
+ document.addEventListener('click', handler, true);
95
+ ctx.effect(() => () => document.removeEventListener('click', handler, true), 'dsh-webui-oauth: deepseek relay hook');
96
+ })();
97
+
71
98
  // ---------------- DeepSeek 登录回调:合并进官方「等待登录」对话框 ----------------
72
99
  //
73
100
  // 官方账号登录是 SignInDialog(另一个插件)渲染的,没有开放插槽;但它等待
@@ -105,8 +132,16 @@ window.__ModuleLoader__.load({
105
132
  submit.type = 'button';
106
133
  submit.className = 'wua-btn';
107
134
  submit.textContent = '完成登录';
135
+ const pop = document.createElement('button');
136
+ pop.type = 'button';
137
+ pop.className = 'wua-btn ghost';
138
+ pop.textContent = '在新窗口打开';
139
+ pop.addEventListener('click', () => {
140
+ window.open('/dsh-webui-oauth/relay-window', 'dsh-wua-relay', 'width=480,height=620');
141
+ });
108
142
  row.appendChild(input);
109
143
  row.appendChild(submit);
144
+ row.appendChild(pop);
110
145
 
111
146
  const msg = document.createElement('p');
112
147
  msg.style.display = 'none';
@@ -276,8 +311,8 @@ window.__ModuleLoader__.load({
276
311
  // (服务端只回传掩码,前端拿不到 sub 明文,也不应该拿)。
277
312
  const [oidcBound, setOidcBound] = react.useState(false);
278
313
  const [oidcBoundHint, setOidcBoundHint] = react.useState('');
279
- const [unbindPw, setUnbindPw] = react.useState('');
280
314
  const [bindMsg, setBindMsg] = react.useState(null);
315
+ const [bindWaiting, setBindWaiting] = react.useState(false);
281
316
  // 敏感操作复验(保存配置 / 禁用 / 解绑):密码或 SSO 一次性令牌。
282
317
  const [saveOpen, setSaveOpen] = react.useState(false);
283
318
  const [savePw, setSavePw] = react.useState('');
@@ -289,6 +324,9 @@ window.__ModuleLoader__.load({
289
324
  const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
290
325
  const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
291
326
  // 设备会话(App / CLI):独立列表,只有主动吊销才失效。
327
+ const [sessionList, setSessionList] = react.useState(null);
328
+ const [sessionBusy, setSessionBusy] = react.useState(false);
329
+ const [sessionMsg, setSessionMsg] = react.useState(null);
292
330
  const [deviceList, setDeviceList] = react.useState(null);
293
331
  const [deviceBusy, setDeviceBusy] = react.useState(false);
294
332
  const [deviceMsg, setDeviceMsg] = react.useState(null);
@@ -338,6 +376,10 @@ window.__ModuleLoader__.load({
338
376
  if (!alive) return;
339
377
  if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
340
378
  }).catch(() => { /* 设备列表不可用时静默 */ });
379
+ api('/dsh-webui-oauth/sessions').then((r) => {
380
+ if (!alive) return;
381
+ if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
382
+ }).catch(() => { /* 会话列表不可用时静默 */ });
341
383
  return () => {
342
384
  alive = false;
343
385
  };
@@ -346,6 +388,20 @@ window.__ModuleLoader__.load({
346
388
  // 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
347
389
  // 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
348
390
  // 组件重新挂载时才知道刚才发生了什么。
391
+ // 会话列表实时刷新:UA/IP 由服务端按每个 HTTP 请求更新,这里定期拉取。
392
+ react.useEffect(() => {
393
+ if (typeof setInterval !== 'function') return;
394
+ const timer = setInterval(() => {
395
+ api('/dsh-webui-oauth/sessions').then((r) => {
396
+ if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
397
+ }).catch(() => {});
398
+ api('/dsh-webui-oauth/devices').then((r) => {
399
+ if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
400
+ }).catch(() => {});
401
+ }, 15000);
402
+ return () => { if (typeof clearInterval === 'function') clearInterval(timer); };
403
+ }, []);
404
+
349
405
  // 复验弹窗通过 postMessage 通知;同时轮询 status 兜底(弹窗被拦截/关不掉)。
350
406
  react.useEffect(() => {
351
407
  if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
@@ -357,6 +413,18 @@ window.__ModuleLoader__.load({
357
413
  if (s && !s.error) setStatus(s);
358
414
  }).catch(() => {});
359
415
  }
416
+ if (e.data && e.data.type === 'dsh-wua-bound' && e.data.ok) {
417
+ setBindWaiting(false);
418
+ api('/dsh-webui-oauth/status').then((s) => {
419
+ if (s && !s.error) {
420
+ setStatus(s);
421
+ const o = s.oidc || {};
422
+ setOidcBound(!!o.bound);
423
+ setOidcBoundHint(o.boundSubHint || '');
424
+ }
425
+ }).catch(() => {});
426
+ setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' });
427
+ }
360
428
  };
361
429
  window.addEventListener('message', onMessage);
362
430
  return () => window.removeEventListener('message', onMessage);
@@ -463,7 +531,10 @@ window.__ModuleLoader__.load({
463
531
  setBusy(false);
464
532
  if (r && r.ok) {
465
533
  if (enabled) {
466
- setMsg({ kind: 'ok', text: '已保存。其他已登录的设备/浏览器会话已全部吊销,当前会话保持有效。' });
534
+ const text = r.passwordChanged
535
+ ? '已保存。密码已更新,已吊销 ' + (r.revoked || 0) + ' 个其他浏览器会话(设备会话不受影响)。'
536
+ : '已保存。当前会话保持有效。';
537
+ setMsg({ kind: 'ok', text });
467
538
  } else {
468
539
  setMsg({ kind: 'ok', text: '已保存并启用认证,当前浏览器已登录。' });
469
540
  }
@@ -572,28 +643,39 @@ window.__ModuleLoader__.load({
572
643
  setBindMsg(null);
573
644
  // base 用当前页面的 origin:反代部署下浏览器看到的才是对外地址。
574
645
  const base = location.origin || '';
575
- location.href = '/dsh-webui-oauth/oidc/bind' + (base ? '?base=' + encodeURIComponent(base) : '');
646
+ const q = base ? '?base=' + encodeURIComponent(base) + '&popup=1' : '?popup=1';
647
+ const win = window.open('/dsh-webui-oauth/oidc/bind' + q, 'dsh-wua-bind', 'width=520,height=720');
648
+ if (!win) {
649
+ // 弹窗被拦截:回退整页跳转(回调会走 oidcbound=1 回执)
650
+ location.href = '/dsh-webui-oauth/oidc/bind' + (base ? '?base=' + encodeURIComponent(base) : '');
651
+ return;
652
+ }
653
+ setBindWaiting(true);
576
654
  };
577
655
 
578
- const onUnbind = () => {
656
+ const doUnbind = (auth) => {
579
657
  if (busy) return;
580
- if (!unbindPw) {
581
- setBindMsg({ kind: 'err', text: '请输入当前密码以解除绑定' });
658
+ const credentials = auth || {};
659
+ if (!credentials.current && !reauthValid) {
660
+ setSaveErr('解除绑定需要验证:请输入当前密码,或使用 SSO 验证。');
582
661
  return;
583
662
  }
584
663
  setBusy(true);
585
664
  setBindMsg(null);
586
- api('/dsh-webui-oauth/oidc/unbind', { current: unbindPw }).then((r) => {
665
+ api('/dsh-webui-oauth/oidc/unbind', { current: credentials.current || '' }).then((r) => {
587
666
  setBusy(false);
588
667
  if (r && r.ok) {
589
- setUnbindPw('');
590
668
  setOidcBound(false);
591
669
  setOidcBoundHint('');
670
+ setSaveOpen(false);
671
+ setSavePw('');
672
+ setSaveErr('');
592
673
  setBindMsg({ kind: 'ok', text: '已解除绑定。此后 OIDC 登录将被拒绝,直到重新绑定。' });
593
674
  api('/dsh-webui-oauth/status').then((s) => {
594
675
  if (s && !s.error) setStatus(s);
595
676
  }).catch(() => {});
596
677
  } else if (r && r.error === 'current-invalid') {
678
+ setSaveErr('当前密码不正确');
597
679
  setBindMsg({ kind: 'err', text: '当前密码不正确' });
598
680
  } else if (r && r.error === 'sso-only-active') {
599
681
  setBindMsg({ kind: 'err', text: (r && r.hint) || '请先关闭「仅允许 SSO」再解绑' });
@@ -606,6 +688,14 @@ window.__ModuleLoader__.load({
606
688
  });
607
689
  };
608
690
 
691
+ const onUnbindClick = () => {
692
+ if (busy) return;
693
+ if (reauthValid) { doUnbind({}); return; }
694
+ setVerifyAction('unbind');
695
+ setSaveErr('');
696
+ setSaveOpen(true);
697
+ };
698
+
609
699
  const onLogout = () => {
610
700
  if (busy) return;
611
701
  setBusy(true);
@@ -683,6 +773,30 @@ window.__ModuleLoader__.load({
683
773
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
684
774
  }
685
775
 
776
+ const onRevokeSession = (id) => {
777
+ if (sessionBusy) return;
778
+ setSessionBusy(true);
779
+ setSessionMsg(null);
780
+ api('/dsh-webui-oauth/sessions/revoke-one', { id }).then((r) => {
781
+ setSessionBusy(false);
782
+ if (r && r.ok) {
783
+ setSessionList((list) => (list || []).filter((s) => s.id !== id));
784
+ setSessionMsg({ kind: 'ok', text: '已吊销该浏览器会话。' });
785
+ } else {
786
+ setSessionMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '吊销失败' });
787
+ }
788
+ }).catch(() => {
789
+ setSessionBusy(false);
790
+ setSessionMsg({ kind: 'err', text: '无法连接认证服务' });
791
+ });
792
+ };
793
+
794
+ const refreshSessions = () => {
795
+ api('/dsh-webui-oauth/sessions').then((r) => {
796
+ if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
797
+ }).catch(() => {});
798
+ };
799
+
686
800
  const onRevokeDevice = (id) => {
687
801
  if (deviceBusy) return;
688
802
  setDeviceBusy(true);
@@ -857,20 +971,15 @@ window.__ModuleLoader__.load({
857
971
  '。只有该身份可通过 SSO 登录。')
858
972
  : react.createElement('p', { className: 'wua-warn' },
859
973
  '尚未绑定任何 OIDC 身份。此时 OIDC 登录一律被拒绝,登录页也不会显示 SSO 按钮。请先保存上方配置,再点击「绑定 OIDC 身份」并完成一次 IdP 登录。'),
860
- oidcBound
861
- ? react.createElement('div', { className: 'wua-row' },
862
- react.createElement('label', { className: 'wua-label' }, '当前密码(解除绑定需验证)'),
863
- react.createElement('input', { className: 'wua-input', type: 'password', value: unbindPw, autoComplete: 'current-password', onChange: (e) => setUnbindPw(e.target.value) }),
864
- )
865
- : null,
866
974
  react.createElement('div', { className: 'wua-actions' },
867
975
  oidcBound
868
- ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onUnbind }, '解除绑定')
976
+ ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onUnbindClick }, '解除绑定')
869
977
  : react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onBind }, '绑定 OIDC 身份'),
870
978
  oidcBound
871
979
  ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onBind }, '重新绑定其他身份')
872
980
  : null,
873
981
  ),
982
+ bindWaiting ? react.createElement('p', { className: 'wua-msg' }, '等待绑定…请在打开的窗口中完成 IdP 授权。') : null,
874
983
  bindMsg ? react.createElement('p', { className: bindMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, bindMsg.text) : null,
875
984
  ]) : null,
876
985
  card('远程访问', [
@@ -893,6 +1002,38 @@ window.__ModuleLoader__.load({
893
1002
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
894
1003
  '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
895
1004
  ]),
1005
+ card('浏览器会话', [
1006
+ sessionList === null ? null : badge(String(sessionList.length) + ' 个', sessionList.length ? 'ok' : 'muted'),
1007
+ ], [
1008
+ react.createElement('p', { className: 'wua-intro' },
1009
+ '当前已登录的浏览器。可单独吊销某个会话;设备会话在下一张卡片单独管理。'),
1010
+ sessionList === null
1011
+ ? react.createElement('p', { className: 'wua-intro' }, '正在读取…')
1012
+ : (sessionList.length === 0
1013
+ ? react.createElement('p', { className: 'wua-intro' }, '暂无活动的浏览器会话')
1014
+ : react.createElement('div', { className: 'wua-audit' },
1015
+ sessionList.map((s) => react.createElement('div', {
1016
+ key: s.id,
1017
+ className: 'wua-audit-row',
1018
+ style: { alignItems: 'center' },
1019
+ },
1020
+ react.createElement('span', { className: 'wua-audit-event' }, s.current ? '当前会话' : (s.device || '浏览器')),
1021
+ react.createElement('span', { className: 'wua-audit-user' }, [s.os, s.browser].filter(Boolean).join(' · ') || '未知设备'),
1022
+ react.createElement('span', { className: 'wua-audit-time' }, '指纹 ' + (s.fingerprint || '—')),
1023
+ react.createElement('span', { className: 'wua-audit-time' }, '最近 ' + fmtTime(s.lastSeenAt)),
1024
+ react.createElement('span', { style: { flex: 1 } }),
1025
+ react.createElement('button', {
1026
+ className: 'wua-btn ghost',
1027
+ disabled: sessionBusy || s.current,
1028
+ title: s.current ? '当前会话请用「退出登录」' : '吊销该会话',
1029
+ onClick: () => onRevokeSession(s.id),
1030
+ }, s.current ? '当前' : '吊销'),
1031
+ )))),
1032
+ react.createElement('div', { className: 'wua-actions' },
1033
+ react.createElement('button', { className: 'wua-btn ghost', onClick: refreshSessions }, '刷新列表'),
1034
+ ),
1035
+ sessionMsg ? react.createElement('p', { className: sessionMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, sessionMsg.text) : null,
1036
+ ]),
896
1037
  card('设备会话(App / CLI)', [
897
1038
  deviceList === null ? null : badge(String(deviceList.length) + ' 个', deviceList.length ? 'ok' : 'muted'),
898
1039
  ], [
@@ -909,8 +1050,13 @@ window.__ModuleLoader__.load({
909
1050
  style: { alignItems: 'center' },
910
1051
  },
911
1052
  react.createElement('span', { className: 'wua-audit-event' }, d.name || '设备'),
912
- react.createElement('span', { className: 'wua-audit-time' }, '创建 ' + fmtTime(d.createdAt)),
913
- d.lastUsedAt ? react.createElement('span', { className: 'wua-audit-time' }, '最近 ' + fmtTime(d.lastUsedAt)) : null,
1053
+ d.description ? react.createElement('span', { className: 'wua-audit-user' }, d.description) : null,
1054
+ react.createElement('span', { className: 'wua-audit-user' },
1055
+ [d.os, d.browser].filter(Boolean).join(' · ') || '尚未连接'),
1056
+ react.createElement('span', { className: 'wua-audit-time' }, '指纹 ' + (d.fingerprint || '—')),
1057
+ react.createElement('span', { className: 'wua-audit-time' },
1058
+ d.lastUsedAt ? '最近 ' + fmtTime(d.lastUsedAt) : '从未使用'),
1059
+ d.lastIp ? react.createElement('span', { className: 'wua-audit-ip' }, d.lastIp) : null,
914
1060
  react.createElement('span', { style: { flex: 1 } }),
915
1061
  react.createElement('button', {
916
1062
  className: 'wua-btn ghost',
@@ -942,7 +1088,10 @@ window.__ModuleLoader__.load({
942
1088
  saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
943
1089
  react.createElement('div', { className: 'wua-modal' },
944
1090
  react.createElement('h4', { className: 'wua-card-title' },
945
- verifyAction === 'disable' ? '验证后禁用' : (verifyAction === 'revoke' ? '验证后吊销会话' : '验证后保存')),
1091
+ verifyAction === 'disable' ? '验证后禁用'
1092
+ : verifyAction === 'revoke' ? '验证后吊销会话'
1093
+ : verifyAction === 'unbind' ? '验证后解绑'
1094
+ : '验证后保存'),
946
1095
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
947
1096
  reauthValid
948
1097
  ? '已通过 SSO 验证(2 分钟内有效),可直接继续。'
@@ -960,8 +1109,13 @@ window.__ModuleLoader__.load({
960
1109
  const auth = reauthValid ? {} : { current: savePw };
961
1110
  if (verifyAction === 'disable') doDisable(auth);
962
1111
  else if (verifyAction === 'revoke') doRevoke(auth);
1112
+ else if (verifyAction === 'unbind') doUnbind(auth);
963
1113
  else onSave(auth);
964
- } }, busy ? '处理中…' : (verifyAction === 'disable' ? '验证并禁用' : (verifyAction === 'revoke' ? '验证并吊销' : '验证并保存'))),
1114
+ } }, busy ? '处理中…' : (
1115
+ verifyAction === 'disable' ? '验证并禁用'
1116
+ : verifyAction === 'revoke' ? '验证并吊销'
1117
+ : verifyAction === 'unbind' ? '验证并解绑'
1118
+ : '验证并保存')),
965
1119
  react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
966
1120
  ),
967
1121
  (status && status.oidc && status.oidc.enabled && status.oidc.bound)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {