dsh-webui-oauth 0.7.5 → 0.7.7

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,64 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.7.7
6
+
7
+ 设置页再收敛:移除冗余密码框、保存按钮移到顶部、SSO 复验改为会话级。
8
+
9
+ ### 调整:账号卡片不再有「当前密码」输入框
10
+
11
+ 保存/禁用已经统一走验证弹窗,账号卡片里的「当前密码」输入框是冗余的,已移除。
12
+ 禁用认证改为与保存一致的流程:顶部按钮 → 弹窗验证(密码或 SSO)。
13
+
14
+ ### 调整:「保存设置」从右下角悬浮改为顶部卡片按钮
15
+
16
+ - 移除右下角悬浮的「保存设置」按钮;
17
+ - 保存按钮放到「概览」卡片的操作区(与退出登录、禁用认证并列);
18
+ - 首次初始化(未启用认证)直接保存,无需验证;
19
+ - 已启用时点击弹窗验证:当前密码,或 SSO 验证。
20
+
21
+ ### 调整:SSO 复验改为**会话级**,不再签发独立令牌
22
+
23
+ - 复验回调仍走统一的 `/dsh-webui-oauth/oidc/callback`;
24
+ - 发起复验时把当前会话 token 记进 OIDC state(服务端);
25
+ - 回调校验 sub 通过后,直接给该会话写 `reauthAt = now`(2 分钟有效),
26
+ **不再生成令牌、也不再往 URL 里放令牌**;
27
+ - `configure` / `disable` / `oidc/unbind` 通过 `sessionReauthOk(req)` 判断;
28
+ 成功后清除 `reauthAt`(一次性);
29
+ - `status` 回显 `reauthActive`,前端据此显示「已通过 SSO 验证」;
30
+ - 回调回执只保留 `oidcreauth=1` 状态标记。
31
+
32
+ ### 测试
33
+
34
+ - `test/oidc-bind.test.mjs` 的复验用例改为会话级断言(回调只带状态标记、
35
+ 同会话无密码保存成功、复验不可重放);
36
+ - 全量 22 个测试文件、599 项断言通过。
37
+
38
+ ## 0.7.6
39
+
40
+ 把 DeepSeek 登录回调输入**合并进官方「等待登录」对话框**,不再单独占一个悬浮入口。
41
+
42
+ ### 调整:回调粘贴框内联到等待登录窗口
43
+
44
+ - 官方账号登录的 `SignInDialog` 在等待浏览器时显示固定文案
45
+ (「没有自动打开新页面?」/「Page did not open automatically?」);
46
+ - 插件用 `MutationObserver` 找到这段文案,把「粘贴回调 URL」输入框注入到
47
+ **同一个对话框**里,文案变化/对话框关闭时自动移除;
48
+ - 移除 0.7.5 的独立「DeepSeek 登录回调」悬浮按钮;
49
+ - 这是尽力而为的 DOM 适配:官方 UI 改版时最多退化成"不显示输入框",
50
+ 不影响登录本身;`POST /dsh-webui-oauth/oauth-relay` 端点保持可用,
51
+ 也可以从控制台/其他入口调用。
52
+
53
+ ### 保持不变
54
+
55
+ - 回调仍然**不公开**,中转端点要求会话;
56
+ - 输入 URL 只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`;
57
+ - state/PKCE 由账号平台校验。
58
+
59
+ ### 测试
60
+
61
+ - 客户端源码/回填测试通过;全量 22 个测试文件、599 项断言通过。
62
+
5
63
  ## 0.7.5
6
64
 
7
65
  设置页交互修正:登录回调改成全局悬浮窗、远程信任可一键开关、保存改为右下角悬浮按钮 + 二次验证。
package/README.en.md CHANGED
@@ -429,10 +429,10 @@ wired into `npm test`).
429
429
  `http://127.0.0.1:<local port>`;
430
430
  - after authorization the browser is redirected to that loopback callback — usually unreachable from
431
431
  a remote machine, but the full URL stays in the address bar;
432
- - click the always-on "DeepSeek 登录回调" floating button at the bottom-right of any page and paste
433
- the URL; the plugin **replays it internally** to the account platform's registered
434
- `/oauth/callback` handler (state and PKCE are still validated by the platform) to finish the
435
- token exchange;
432
+ - the official "Waiting for sign in" dialog grows a callback paste box (the plugin injects it into
433
+ that dialog via MutationObserver; there is no separate floating entry); paste the URL and the plugin
434
+ **replays it internally** to the account platform's registered `/oauth/callback` handler (state and
435
+ PKCE are still validated by the platform) to finish the token exchange;
436
436
  - the relay endpoint requires the plugin session and accepts only the local port's
437
437
  `/oauth/callback` with exactly one `code`/`state` (SSRF / open-redirect protection);
438
438
  - on `http://127.0.0.1:3080` the official login already completes directly; no relay needed.
package/README.md CHANGED
@@ -423,7 +423,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
423
423
  - **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>`(显式非零端口),不支持反代域名。插件的处理方式:
424
424
  - 劫持官方登录的 `callbackOrigin`:本地回环访问保留原 origin(cookie 同 host,回调直接命中);域名访问改写成 `http://127.0.0.1:<本机端口>`;
425
425
  - 浏览器授权后会被跳到这个回环回调地址——远程机器上通常打不开,但地址栏里有完整 URL;
426
- - 点击页面右下角常驻的「DeepSeek 登录回调」悬浮按钮,把地址栏 URL 粘贴进去;插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
426
+ - 官方「等待登录」窗口里会直接出现一个回调粘贴框(插件用 MutationObserver 注入到该对话框内,不单独占一个悬浮入口);把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
427
427
  - 中转端点要求插件会话,且只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`(防 SSRF / 开放重定向);
428
428
  - 本机 `http://127.0.0.1:3080` 访问时官方登录原本就能直接走完,无需中转。
429
429
  - **DSH Desktop**:支持 `dsh-desktop-*` 标记透传(登录 / 退出后重登不再丢失标记),但**不提供**上游 0.3.6 的「宿主载体桥」。该桥只用 socket 是否回环来判定,而本部署是 nginx 同机反代——所有请求的 socket 都是 `127.0.0.1`,启用它等于"持有核心 Cookie 即可绕过插件会话 / SSO",与「仅允许 SSO」冲突。若必须使用 DSH Desktop 的 shell 直连模式,请改用上游原版插件,并自行评估该旁路风险。
package/index.js CHANGED
@@ -2691,6 +2691,31 @@ export async function apply(ctx) {
2691
2691
 
2692
2692
  const SESSION_TTL_MS = SESSION_BROWSER_TTL_MS
2693
2693
 
2694
+ /** 取当前请求对应的会话记录(不做续期),无效/过期返回 null。 */
2695
+ function sessionOf(req) {
2696
+ const token = cookieOf(req, COOKIE_NAME)
2697
+ if (!token) return null
2698
+ const s = sessions.get(token)
2699
+ if (!s) return null
2700
+ if (s.expiresAt <= Date.now()) {
2701
+ sessions.delete(token)
2702
+ return null
2703
+ }
2704
+ return s
2705
+ }
2706
+
2707
+ /** 该会话是否在 2 分钟内通过过 SSO 复验。 */
2708
+ function sessionReauthOk(req) {
2709
+ const s = sessionOf(req)
2710
+ return !!(s && typeof s.reauthAt === 'number' && Date.now() - s.reauthAt < REAUTH_WINDOW_MS)
2711
+ }
2712
+
2713
+ /** 复验是一次性的:敏感操作成功后清掉时间戳。 */
2714
+ function clearSessionReauth(req) {
2715
+ const s = sessionOf(req)
2716
+ if (s) delete s.reauthAt
2717
+ }
2718
+
2694
2719
  function createSession(username, ttl) {
2695
2720
  const token = randomBytes(24).toString('hex')
2696
2721
  const ttlMs = ttl > 0 ? ttl * 3600 * 1000 : SESSION_TTL_MS
@@ -2832,30 +2857,20 @@ export async function apply(ctx) {
2832
2857
  // 必须在后台定时器使用 purge 之前定义。
2833
2858
  const oidcStateStore = makeOidcStateStore()
2834
2859
 
2835
- // 敏感操作(保存配置 / 禁用 / 解绑)的 SSO 复验令牌:
2836
- // 一次性、2 分钟。密码被禁用(仅允许 SSO)时,用户仍可用 SSO 完成二次验证。
2837
- const reauthTokens = new Map()
2838
- const REAUTH_TTL_MS = 2 * 60 * 1000
2839
- const mintReauthToken = (username) => {
2840
- const token = randomBytes(16).toString('hex')
2841
- reauthTokens.set(token, { username, expiresAt: Date.now() + REAUTH_TTL_MS })
2842
- return token
2843
- }
2844
- const consumeReauthToken = (token) => {
2845
- if (typeof token !== 'string' || !token) return null
2846
- const rec = reauthTokens.get(token)
2847
- if (!rec) return null
2848
- reauthTokens.delete(token)
2849
- if (rec.expiresAt <= Date.now()) return null
2850
- return rec
2851
- }
2860
+ // SSO 复验通过后,直接在**当前会话**上记一个时间戳(2 分钟有效),
2861
+ // 不再签发独立令牌、也不经过 URL 传递。敏感操作直接检查会话。
2862
+ const REAUTH_WINDOW_MS = 2 * 60 * 1000
2852
2863
  const purgeExpiredOidcStates = oidcStateStore.purge
2853
2864
 
2854
2865
  // 后台任务:过期会话清理 + enabled 状态刷新
2855
2866
  const bgTimer = setInterval(async () => {
2856
2867
  const now = Date.now()
2857
- for (const [k, s] of sessions.live) if (s.expiresAt <= now) sessions.delete(k)
2858
- for (const [k, v] of reauthTokens) if (v.expiresAt <= now) reauthTokens.delete(k)
2868
+ for (const [k, s] of sessions.live) {
2869
+ if (s.expiresAt <= now || (typeof s.reauthAt === 'number' && now - s.reauthAt >= REAUTH_WINDOW_MS)) {
2870
+ if (s.expiresAt <= now) sessions.delete(k)
2871
+ else delete s.reauthAt
2872
+ }
2873
+ }
2859
2874
  purgeExpiredOidcStates()
2860
2875
  try {
2861
2876
  const creds = await readCredentials(ctx)
@@ -3124,6 +3139,8 @@ export async function apply(ctx) {
3124
3139
  sessionsPersisted: sessions.ok,
3125
3140
  // 远程访问信任(可选):是否已注入 ownsHost。
3126
3141
  remoteTrust: remoteTrustFlag,
3142
+ // 当前会话是否在 2 分钟内通过过 SSO 复验(保存/禁用/解绑可直接进行)。
3143
+ reauthActive: sessionReauthOk(req),
3127
3144
  // 核心兜底席位形态(handler | resource | absent | unknown):核心升级后
3128
3145
  // 如果这里变成 unknown,文档请求会 404 —— 排查时先看这个字段。
3129
3146
  fallback: fallbackShape(ctx.webServer),
@@ -3243,8 +3260,8 @@ export async function apply(ctx) {
3243
3260
  }
3244
3261
  }
3245
3262
  if (wasEnabled) {
3246
- // 支持 SSO 复验令牌(「仅允许 SSO」时密码登录入口不可用)
3247
- const reauthOk = !!consumeReauthToken(body.reauthToken)
3263
+ // 支持 SSO 复验(「仅允许 SSO」时密码登录入口不可用):复验结果记在会话上
3264
+ const reauthOk = sessionReauthOk(req)
3248
3265
  const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3249
3266
  if (!curValid) {
3250
3267
  await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
@@ -3383,6 +3400,7 @@ export async function apply(ctx) {
3383
3400
  if (!keepPassword) detailParts.push('密码已修改')
3384
3401
  if (body.ttl !== undefined) detailParts.push('有效期已修改')
3385
3402
  await auditLog(ctx, 'configure_success', { username, ip: meta.ip, ua: meta.ua, detail: detailParts.length ? detailParts.join(',') : null })
3403
+ clearSessionReauth(req)
3386
3404
  sendJson(res, 200, { ok: true })
3387
3405
  } catch (e) {
3388
3406
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
@@ -3412,7 +3430,7 @@ export async function apply(ctx) {
3412
3430
  sendJson(res, 200, { ok: true })
3413
3431
  return
3414
3432
  }
3415
- const reauthOk = !!consumeReauthToken(body.reauthToken)
3433
+ const reauthOk = sessionReauthOk(req)
3416
3434
  const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3417
3435
  if (!curValid) {
3418
3436
  await auditLog(ctx, 'disable_failure', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
@@ -3498,7 +3516,10 @@ export async function apply(ctx) {
3498
3516
  const state = oidcRandom(24)
3499
3517
  const nonce = oidcRandom(24)
3500
3518
  const verifier = oidcRandom(32)
3501
- oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose })
3519
+ // 复验要绑定到发起它的浏览器的**会话**:回调时直接把 reauthAt 记在该会话上,
3520
+ // 不再签发独立令牌、也不把令牌放进 URL。
3521
+ const sessionToken = purpose === 'reauth' ? cookieOf(req, COOKIE_NAME) : null
3522
+ oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken })
3502
3523
  const authUrl = new URL(disc.metadata.authorization_endpoint)
3503
3524
  authUrl.searchParams.set('response_type', 'code')
3504
3525
  authUrl.searchParams.set('client_id', o.clientId)
@@ -3629,7 +3650,7 @@ export async function apply(ctx) {
3629
3650
  const creds = await readCredentials(ctx)
3630
3651
  const meta = requestMeta(req)
3631
3652
  const current = String(body.current || '')
3632
- const reauthOk = !!consumeReauthToken(body.reauthToken)
3653
+ const reauthOk = sessionReauthOk(req)
3633
3654
  const curValid = reauthOk || (current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3634
3655
  if (!curValid) {
3635
3656
  await auditLog(ctx, 'oidc_unbind_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
@@ -3662,6 +3683,7 @@ export async function apply(ctx) {
3662
3683
  await writeCredentials(ctx, next)
3663
3684
  await refreshSsoOnly(next)
3664
3685
  await auditLog(ctx, 'oidc_unbind_success', { username: creds.username, ip: meta.ip, ua: meta.ua })
3686
+ clearSessionReauth(req)
3665
3687
  sendJson(res, 200, { ok: true, bound: false })
3666
3688
  } catch (e) {
3667
3689
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
@@ -3793,9 +3815,16 @@ export async function apply(ctx) {
3793
3815
  redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
3794
3816
  return
3795
3817
  }
3796
- const token = mintReauthToken(rawSub)
3818
+ // 把复验结果记到发起登录的那个会话上(2 分钟内有效)。
3819
+ const target = st.sessionToken ? sessions.get(st.sessionToken) : null
3820
+ if (!target || target.expiresAt <= Date.now()) {
3821
+ await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '复验时会话已失效' })
3822
+ redirectBackToApp(ctx, req, res, 'oidcerr=session-expired', clearStateCookie)
3823
+ return
3824
+ }
3825
+ target.reauthAt = Date.now()
3797
3826
  await auditLog(ctx, 'oidc_reauth_success', { username: auditName, ip: meta.ip, ua: meta.ua })
3798
- redirectBackToApp(ctx, req, res, 'oidcreauth=' + token, clearStateCookie)
3827
+ redirectBackToApp(ctx, req, res, 'oidcreauth=1', clearStateCookie)
3799
3828
  return
3800
3829
  }
3801
3830
 
package/lib/client.js CHANGED
@@ -25,10 +25,8 @@ window.__ModuleLoader__.load({
25
25
  '.wua-float { position: fixed; right: 24px; bottom: 24px; z-index: 9999; width: min(440px, calc(100vw - 48px)); display: flex; flex-direction: column; gap: 10px; padding: 16px 18px; border: 1px solid var(--dsw-alias-border-l2, #999); border-radius: 12px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 18px 48px rgba(0, 0, 0, .22); }',
26
26
  '.wua-float-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }',
27
27
  '.wua-float-title { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #222); }',
28
- '.wua-fab { position: fixed; right: 24px; bottom: 96px; z-index: 9998; padding: 9px 16px; font-size: 13px; cursor: pointer; color: #fff; background: var(--dsw-alias-brand-primary, #4a7cf7); border: none; border-radius: 999px; box-shadow: 0 10px 26px rgba(74, 124, 247, .35); }',
29
- '.wua-fab:hover { filter: brightness(1.05); }',
30
- '.wua-save-fab { position: fixed; right: 24px; bottom: 24px; z-index: 9997; padding: 10px 20px; font-size: 13px; font-weight: 600; cursor: pointer; color: #fff; background: var(--dsw-alias-brand-primary, #4a7cf7); border: none; border-radius: 999px; box-shadow: 0 12px 30px rgba(74, 124, 247, .38); }',
31
- '.wua-save-fab:disabled { opacity: .55; cursor: default; }',
28
+ '.wua-inline-relay { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--dsw-alias-border-l1, #d9d9d9); }',
29
+
32
30
  '.wua-modal-mask { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(0, 0, 0, .45); }',
33
31
  '.wua-modal { width: min(420px, 100%); display: flex; flex-direction: column; gap: 12px; padding: 20px 22px; border-radius: 12px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 60px rgba(0, 0, 0, .35); }',
34
32
  '.wua-intro { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #888); }',
@@ -66,108 +64,118 @@ window.__ModuleLoader__.load({
66
64
  if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
67
65
  }, 'dsh-webui-oauth: styles');
68
66
 
69
- // ---------------- 全局悬浮:DeepSeek 登录回调中转 ----------------
70
- // 不放在设置页里:官方登录是在「设置 → 账号」发起的,回调地址可能在任何
71
- // 页面/标签页拿到。这里挂一个全局悬浮按钮,任何页面都能打开粘贴窗口。
72
- (function mountRelayWidget() {
67
+ // ---------------- DeepSeek 登录回调:合并进官方「等待登录」对话框 ----------------
68
+ //
69
+ // 官方账号登录是 SignInDialog(另一个插件)渲染的,没有开放插槽;但它等待
70
+ // 浏览器时会显示一段固定文案("没有自动打开新页面?" / "Page did not open
71
+ // automatically?")。这里用 MutationObserver 找到那段文案,把回调输入框
72
+ // 插到同一个对话框里——不再单独挂一个悬浮按钮。
73
+ //
74
+ // 这是**尽力而为**的 DOM 适配:官方 UI 改版时最多退化成"不显示输入框",
75
+ // 不影响登录本身;/dsh-webui-oauth/oauth-relay 端点仍然可用。
76
+ (function mountInlineRelay() {
73
77
  if (typeof document === 'undefined' || !document.body) return;
74
- if (document.getElementById('dsh-wua-relay')) return;
75
-
76
- const wrap = document.createElement('div');
77
- wrap.id = 'dsh-wua-relay';
78
-
79
- const fab = document.createElement('button');
80
- fab.type = 'button';
81
- fab.className = 'wua-fab';
82
- fab.textContent = 'DeepSeek 登录回调';
83
- fab.title = '官方 DeepSeek 登录的回调中转';
84
-
85
- const panel = document.createElement('div');
86
- panel.className = 'wua-float';
87
- panel.style.display = 'none';
88
-
89
- const head = document.createElement('div');
90
- head.className = 'wua-float-head';
91
- const title = document.createElement('span');
92
- title.className = 'wua-float-title';
93
- title.textContent = 'DeepSeek 登录回调中转';
94
- const close = document.createElement('button');
95
- close.type = 'button';
96
- close.className = 'wua-btn ghost';
97
- close.textContent = '关闭';
98
- head.appendChild(title);
99
- head.appendChild(close);
100
-
101
- const hint = document.createElement('p');
102
- hint.className = 'wua-hint';
103
- hint.style.borderTop = 'none';
104
- hint.style.paddingTop = '0';
105
- hint.textContent = '把浏览器地址栏里的完整回调 URL 粘贴到这里。只接受 http://127.0.0.1 或 localhost 的本机端口地址。';
106
-
107
- const input = document.createElement('input');
108
- input.className = 'wua-input';
109
- input.type = 'text';
110
- input.placeholder = 'http://127.0.0.1:3080/oauth/callback?code=…&state=…';
111
-
112
- const actions = document.createElement('div');
113
- actions.className = 'wua-actions';
114
- const submit = document.createElement('button');
115
- submit.type = 'button';
116
- submit.className = 'wua-btn';
117
- submit.textContent = '完成登录';
118
- actions.appendChild(submit);
119
-
120
- const msg = document.createElement('p');
121
- msg.style.display = 'none';
122
-
123
- panel.appendChild(head);
124
- panel.appendChild(hint);
125
- panel.appendChild(input);
126
- panel.appendChild(actions);
127
- panel.appendChild(msg);
128
- wrap.appendChild(fab);
129
- wrap.appendChild(panel);
130
- document.body.appendChild(wrap);
131
-
132
- const show = (text, ok) => {
133
- msg.textContent = text;
134
- msg.className = ok ? 'wua-msg' : 'wua-err';
135
- msg.style.display = 'block';
78
+ const PROMPTS = ['没有自动打开新页面', 'Page did not open automatically'];
79
+ const MARK = 'data-dsh-wua-relay-inline';
80
+ let timer = null;
81
+ const injected = new Set();
82
+
83
+ const build = () => {
84
+ const box = document.createElement('div');
85
+ box.className = 'wua-inline-relay';
86
+ box.setAttribute(MARK, '1');
87
+
88
+ const label = document.createElement('div');
89
+ label.className = 'wua-label';
90
+ label.textContent = '远程访问?把浏览器地址栏里的完整回调 URL 粘贴到这里';
91
+
92
+ const row = document.createElement('div');
93
+ row.className = 'wua-actions';
94
+ row.style.alignItems = 'stretch';
95
+ const input = document.createElement('input');
96
+ input.className = 'wua-input';
97
+ input.type = 'text';
98
+ input.placeholder = 'http://127.0.0.1:3080/oauth/callback?code=…&state=…';
99
+ input.style.flex = '1';
100
+ const submit = document.createElement('button');
101
+ submit.type = 'button';
102
+ submit.className = 'wua-btn';
103
+ submit.textContent = '完成登录';
104
+ row.appendChild(input);
105
+ row.appendChild(submit);
106
+
107
+ const msg = document.createElement('p');
108
+ msg.style.display = 'none';
109
+ box.appendChild(label);
110
+ box.appendChild(row);
111
+ box.appendChild(msg);
112
+
113
+ const show = (text, ok) => {
114
+ msg.textContent = text;
115
+ msg.className = ok ? 'wua-msg' : 'wua-err';
116
+ msg.style.display = 'block';
117
+ };
118
+ submit.addEventListener('click', () => {
119
+ const url = input.value.trim();
120
+ if (!url) { show('请粘贴完整回调地址', false); return; }
121
+ submit.disabled = true;
122
+ fetch('/dsh-webui-oauth/oauth-relay', {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify({ url }),
126
+ }).then((r) => {
127
+ if (r.status === 401) { location.href = '/dsh-webui-oauth/login'; return null; }
128
+ return r.json();
129
+ }).then((res) => {
130
+ submit.disabled = false;
131
+ if (!res) return;
132
+ if (res.ok) { show('已收到回调,正在完成登录…', true); input.value = ''; }
133
+ else show(res.hint || res.reason || res.error || '中转失败', false);
134
+ }).catch(() => { submit.disabled = false; show('无法连接认证服务', false); });
135
+ });
136
+ return box;
136
137
  };
137
- const setOpen = (open) => {
138
- panel.style.display = open ? 'flex' : 'none';
139
- fab.style.display = open ? 'none' : 'inline-flex';
138
+
139
+ const scan = () => {
140
+ for (const node of injected) {
141
+ const anchor = node.__dshWuaAnchor;
142
+ const stillWaiting = anchor && anchor.isConnected &&
143
+ PROMPTS.some((p) => (anchor.textContent || '').includes(p));
144
+ if (stillWaiting) continue;
145
+ try { node.remove(); } catch (e) { /* ignore */ }
146
+ injected.delete(node);
147
+ }
148
+ const paragraphs = document.querySelectorAll('p');
149
+ for (const el of paragraphs) {
150
+ const text = (el.textContent || '').trim();
151
+ if (!PROMPTS.some((p) => text.includes(p))) continue;
152
+ const host = el.parentElement;
153
+ if (!host || host.querySelector('[' + MARK + ']')) continue;
154
+ const box = build();
155
+ box.__dshWuaAnchor = el;
156
+ host.appendChild(box);
157
+ injected.add(box);
158
+ break;
159
+ }
140
160
  };
141
161
 
142
- fab.addEventListener('click', () => setOpen(true));
143
- close.addEventListener('click', () => setOpen(false));
144
- submit.addEventListener('click', () => {
145
- const url = input.value.trim();
146
- if (!url) { show('请粘贴完整回调地址', false); return; }
147
- submit.disabled = true;
148
- fetch('/dsh-webui-oauth/oauth-relay', {
149
- method: 'POST',
150
- headers: { 'content-type': 'application/json' },
151
- body: JSON.stringify({ url }),
152
- }).then((r) => {
153
- if (r.status === 401) { location.href = '/dsh-webui-oauth/login'; return null; }
154
- return r.json();
155
- }).then((res) => {
156
- submit.disabled = false;
157
- if (!res) return;
158
- if (res.ok) {
159
- show('已收到回调,正在完成登录。请回到「设置 → 账号」查看状态。', true);
160
- input.value = '';
161
- } else {
162
- show(res.hint || res.reason || res.error || '中转失败', false);
163
- }
164
- }).catch(() => {
165
- submit.disabled = false;
166
- show('无法连接认证服务', false);
167
- });
168
- });
162
+ const schedule = () => {
163
+ if (timer !== null) return;
164
+ timer = setTimeout(() => {
165
+ timer = null;
166
+ try { scan(); } catch (e) { /* ignore */ }
167
+ }, 200);
168
+ };
169
169
 
170
- ctx.effect(() => () => wrap.remove(), 'dsh-webui-oauth: relay widget');
170
+ const observer = new MutationObserver(schedule);
171
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
172
+ schedule();
173
+ ctx.effect(() => () => {
174
+ observer.disconnect();
175
+ if (timer !== null) clearTimeout(timer);
176
+ for (const node of injected) { try { node.remove(); } catch (e) { /* ignore */ } }
177
+ injected.clear();
178
+ }, 'dsh-webui-oauth: inline relay');
171
179
  })();
172
180
 
173
181
  // ---------------- 会话失效:统一回登录页 ----------------
@@ -241,7 +249,7 @@ window.__ModuleLoader__.load({
241
249
  const [username, setUsername] = react.useState('');
242
250
  const [password, setPassword] = react.useState('');
243
251
  const [confirm, setConfirm] = react.useState('');
244
- const [current, setCurrent] = react.useState('');
252
+
245
253
  const [ttl, setTtl] = react.useState('12');
246
254
  const [msg, setMsg] = react.useState(null);
247
255
  const [busy, setBusy] = react.useState(false);
@@ -270,8 +278,7 @@ window.__ModuleLoader__.load({
270
278
  const [saveOpen, setSaveOpen] = react.useState(false);
271
279
  const [savePw, setSavePw] = react.useState('');
272
280
  const [saveErr, setSaveErr] = react.useState('');
273
- const [reauthToken, setReauthToken] = react.useState(null);
274
- const [reauthExp, setReauthExp] = react.useState(0);
281
+ const [verifyAction, setVerifyAction] = react.useState('save');
275
282
  // 远程访问信任开关。
276
283
  const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
277
284
  const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
@@ -325,19 +332,6 @@ window.__ModuleLoader__.load({
325
332
  // 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
326
333
  // 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
327
334
  // 组件重新挂载时才知道刚才发生了什么。
328
- // 从 sessionStorage 恢复 SSO 复验令牌(2 分钟内有效)。
329
- react.useEffect(() => {
330
- try {
331
- const raw = sessionStorage.getItem('dsh_wua_reauth')
332
- if (!raw) return
333
- const v = JSON.parse(raw)
334
- if (v && typeof v.token === 'string' && v.exp > Date.now()) {
335
- setReauthToken(v.token)
336
- setReauthExp(v.exp)
337
- }
338
- } catch (e) { /* ignore */ }
339
- }, []);
340
-
341
335
  react.useEffect(() => {
342
336
  let qs = ''
343
337
  try { qs = String((location && location.search) || '') } catch (e) { qs = '' }
@@ -348,10 +342,7 @@ window.__ModuleLoader__.load({
348
342
  const reauth = p.get('oidcreauth')
349
343
  if (bound === null && err === null && reauth === null) return
350
344
  if (reauth !== null) {
351
- try { sessionStorage.setItem('dsh_wua_reauth', JSON.stringify({ token: reauth, exp: Date.now() + 120000 })) } catch (e) { /* ignore */ }
352
- setReauthToken(reauth)
353
- setReauthExp(Date.now() + 120000)
354
- setBindMsg({ kind: 'ok', text: 'SSO 验证成功(2 分钟内有效)。请回到设置页点击「保存设置」。' })
345
+ setBindMsg({ kind: 'ok', text: 'SSO 验证成功(2 分钟内有效)。请点击「保存设置」继续。' })
355
346
  } else if (bound !== null) {
356
347
  setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' })
357
348
  } else {
@@ -400,7 +391,7 @@ window.__ModuleLoader__.load({
400
391
  setMsg({ kind: 'err', text: '两次输入的密码不一致' });
401
392
  return;
402
393
  }
403
- if (enabled && !credentials.current && !credentials.reauthToken) {
394
+ if (enabled && !credentials.current && !reauthValid) {
404
395
  setSaveErr('修改认证信息需要验证:请输入当前密码,或使用 SSO 验证。');
405
396
  return;
406
397
  }
@@ -423,7 +414,6 @@ window.__ModuleLoader__.load({
423
414
  username: name,
424
415
  password,
425
416
  current: credentials.current || '',
426
- reauthToken: credentials.reauthToken || undefined,
427
417
  ttl: Number(ttl),
428
418
  oidc: oidcPayload,
429
419
  }).then((r) => {
@@ -436,13 +426,9 @@ window.__ModuleLoader__.load({
436
426
  }
437
427
  setPassword('');
438
428
  setConfirm('');
439
- setCurrent('');
440
429
  setSaveOpen(false);
441
430
  setSavePw('');
442
431
  setSaveErr('');
443
- setReauthToken(null);
444
- setReauthExp(0);
445
- try { sessionStorage.removeItem('dsh_wua_reauth') } catch (e) { /* ignore */ }
446
432
  api('/dsh-webui-oauth/status').then((s) => {
447
433
  if (s && !s.error) setStatus(s);
448
434
  });
@@ -475,23 +461,27 @@ window.__ModuleLoader__.load({
475
461
  });
476
462
  };
477
463
 
478
- const onDisable = () => {
464
+ const doDisable = (auth) => {
479
465
  if (busy) return;
480
- if (!current) {
481
- setMsg({ kind: 'err', text: '请输入当前密码以禁用认证' });
466
+ const credentials = auth || {};
467
+ if (!credentials.current && !reauthValid) {
468
+ setSaveErr('禁用认证需要验证:请输入当前密码,或使用 SSO 验证。');
482
469
  return;
483
470
  }
484
471
  setBusy(true);
485
472
  setMsg(null);
486
- api('/dsh-webui-oauth/disable', { current }).then((r) => {
473
+ api('/dsh-webui-oauth/disable', { current: credentials.current || '' }).then((r) => {
487
474
  setBusy(false);
488
475
  if (r && r.ok) {
489
476
  setMsg({ kind: 'ok', text: '已禁用认证,所有会话已注销。刷新页面后不再要求登录。' });
490
- setCurrent('');
477
+ setSaveOpen(false);
478
+ setSavePw('');
479
+ setSaveErr('');
491
480
  api('/dsh-webui-oauth/status').then((s) => {
492
481
  if (s && !s.error) setStatus(s);
493
482
  }).catch(() => {});
494
483
  } else {
484
+ setSaveErr('当前密码不正确');
495
485
  setMsg({ kind: 'err', text: '当前密码不正确' });
496
486
  }
497
487
  }).catch(() => {
@@ -554,7 +544,22 @@ window.__ModuleLoader__.load({
554
544
 
555
545
  const enabled = Boolean(status && status.enabled);
556
546
  const remoteTrust = Boolean(status && status.remoteTrust);
557
- const reauthValid = Boolean(reauthToken && Date.now() < reauthExp);
547
+ const reauthValid = Boolean(status && status.reauthActive);
548
+
549
+ // 顶部「保存设置」/「禁用认证」:需要验证时统一打开弹窗(密码或 SSO)。
550
+ const onSaveClick = () => {
551
+ if (!enabled || reauthValid) { onSave({}); return; }
552
+ setVerifyAction('save');
553
+ setSaveErr('');
554
+ setSaveOpen(true);
555
+ };
556
+ const onDisableClick = () => {
557
+ if (!enabled) return;
558
+ if (reauthValid) { doDisable({}); return; }
559
+ setVerifyAction('disable');
560
+ setSaveErr('');
561
+ setSaveOpen(true);
562
+ };
558
563
  const intro = status === null
559
564
  ? '正在读取认证状态…'
560
565
  : (enabled
@@ -638,8 +643,9 @@ window.__ModuleLoader__.load({
638
643
  patchWarn ? react.createElement('p', { className: 'wua-warn' }, patchWarn) : null,
639
644
  persistNote ? react.createElement('p', { className: 'wua-warn' }, persistNote) : null,
640
645
  react.createElement('div', { className: 'wua-actions' },
646
+ react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onSaveClick }, busy ? '保存中…' : '保存设置'),
641
647
  enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onLogout }, '退出登录') : null,
642
- enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onDisable }, '禁用认证') : null,
648
+ enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onDisableClick }, '禁用认证') : null,
643
649
  ),
644
650
  ]),
645
651
  card('账号与密码', [], [
@@ -669,10 +675,6 @@ window.__ModuleLoader__.load({
669
675
  react.createElement('input', { className: 'wua-input', type: 'password', value: confirm, onChange: (e) => setConfirm(e.target.value) }),
670
676
  ),
671
677
  ),
672
- enabled ? react.createElement('div', { className: 'wua-row' },
673
- react.createElement('label', { className: 'wua-label' }, '当前密码(修改或禁用认证需验证)'),
674
- react.createElement('input', { className: 'wua-input', type: 'password', value: current, onChange: (e) => setCurrent(e.target.value) }),
675
- ) : null,
676
678
  msg ? react.createElement('div', { className: msg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, msg.text) : null,
677
679
  ]),
678
680
  card('OIDC 单点登录', [
@@ -780,7 +782,7 @@ window.__ModuleLoader__.load({
780
782
  react.createElement('div', { className: 'wua-sep' }),
781
783
  react.createElement('h4', { className: 'wua-card-title' }, 'DeepSeek 官方账号登录(远程回调)'),
782
784
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
783
- '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。点右下角的「DeepSeek 登录回调」悬浮按钮,把地址栏里的完整 URL 粘贴进去即可。'),
785
+ '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
784
786
  ]),
785
787
  card('最近登录记录', [], [
786
788
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
@@ -798,23 +800,13 @@ window.__ModuleLoader__.load({
798
800
  )),
799
801
  )),
800
802
  ]),
801
- react.createElement('button', {
802
- className: 'wua-save-fab',
803
- disabled: busy || status === null,
804
- onClick: () => {
805
- if (!enabled) { onSave({}); return; }
806
- if (reauthValid) { onSave({ reauthToken }); return; }
807
- setSaveErr('');
808
- setSaveOpen(true);
809
- },
810
- }, busy ? '保存中…' : '保存设置'),
811
803
  saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
812
804
  react.createElement('div', { className: 'wua-modal' },
813
- react.createElement('h4', { className: 'wua-card-title' }, '验证后保存'),
805
+ react.createElement('h4', { className: 'wua-card-title' }, verifyAction === 'disable' ? '验证后禁用' : '验证后保存'),
814
806
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
815
807
  reauthValid
816
- ? '已通过 SSO 验证(2 分钟内有效),可直接保存。'
817
- : '修改认证配置需要验证当前身份:输入当前密码,或使用 SSO 验证。'),
808
+ ? '已通过 SSO 验证(2 分钟内有效),可直接继续。'
809
+ : '该操作需要验证当前身份:输入当前密码,或使用 SSO 验证。'),
818
810
  reauthValid ? null : react.createElement('div', { className: 'wua-row' },
819
811
  react.createElement('label', { className: 'wua-label' }, '当前密码'),
820
812
  react.createElement('input', { className: 'wua-input', type: 'password', value: savePw, autoComplete: 'current-password', onChange: (e) => setSavePw(e.target.value) }),
@@ -822,10 +814,10 @@ window.__ModuleLoader__.load({
822
814
  saveErr ? react.createElement('p', { className: 'wua-err' }, saveErr) : null,
823
815
  react.createElement('div', { className: 'wua-actions' },
824
816
  react.createElement('button', { className: 'wua-btn', disabled: busy, onClick: () => {
825
- if (reauthValid) { onSave({ reauthToken }); return; }
826
- if (!savePw) { setSaveErr('请输入当前密码'); return; }
827
- onSave({ current: savePw });
828
- } }, busy ? '保存中…' : '验证并保存'),
817
+ if (!reauthValid && !savePw) { setSaveErr('请输入当前密码'); return; }
818
+ const auth = reauthValid ? {} : { current: savePw };
819
+ if (verifyAction === 'disable') doDisable(auth); else onSave(auth);
820
+ } }, busy ? '处理中…' : (verifyAction === 'disable' ? '验证并禁用' : '验证并保存')),
829
821
  react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
830
822
  ),
831
823
  (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.7.5",
3
+ "version": "0.7.7",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {