dsh-webui-oauth 0.7.4 → 0.7.6

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,70 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.7.6
6
+
7
+ 把 DeepSeek 登录回调输入**合并进官方「等待登录」对话框**,不再单独占一个悬浮入口。
8
+
9
+ ### 调整:回调粘贴框内联到等待登录窗口
10
+
11
+ - 官方账号登录的 `SignInDialog` 在等待浏览器时显示固定文案
12
+ (「没有自动打开新页面?」/「Page did not open automatically?」);
13
+ - 插件用 `MutationObserver` 找到这段文案,把「粘贴回调 URL」输入框注入到
14
+ **同一个对话框**里,文案变化/对话框关闭时自动移除;
15
+ - 移除 0.7.5 的独立「DeepSeek 登录回调」悬浮按钮;
16
+ - 这是尽力而为的 DOM 适配:官方 UI 改版时最多退化成"不显示输入框",
17
+ 不影响登录本身;`POST /dsh-webui-oauth/oauth-relay` 端点保持可用,
18
+ 也可以从控制台/其他入口调用。
19
+
20
+ ### 保持不变
21
+
22
+ - 回调仍然**不公开**,中转端点要求会话;
23
+ - 输入 URL 只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`;
24
+ - state/PKCE 由账号平台校验。
25
+
26
+ ### 测试
27
+
28
+ - 客户端源码/回填测试通过;全量 22 个测试文件、599 项断言通过。
29
+
30
+ ## 0.7.5
31
+
32
+ 设置页交互修正:登录回调改成全局悬浮窗、远程信任可一键开关、保存改为右下角悬浮按钮 + 二次验证。
33
+
34
+ ### 修复:登录回调中转
35
+
36
+ - **输入框缺失**:旧实现把中转窗渲染在设置页组件内部,受容器布局影响显示不全;
37
+ 现在改为在客户端插件 `apply()` 里挂**全局**悬浮组件(原生 DOM,不依赖 slot),
38
+ 输入框、按钮、提示都完整可见。
39
+ - **不再放在设置页**:右下角常驻「DeepSeek 登录回调」悬浮按钮,任何页面都能打开;
40
+ 设置页只保留说明文字。
41
+
42
+ ### 新增:远程访问信任开关(不再只靠改 YAML)
43
+
44
+ - 设置 → 身份认证 → 远程访问,新增「开启 / 关闭远程访问信任」按钮;
45
+ - 新增 `POST /dsh-webui-oauth/remote-trust`(要求会话):写入插件配置
46
+ `dsh-webui-oauth.json` 的 `remoteTrust`,并**动态**安装/移除 index tap;
47
+ - 配置合并写入,不会覆盖 OIDC 段;
48
+ - `webServer.tapIndex` 不可用时返回 `tap-index-unavailable`,不假装成功;
49
+ - 仍兼容 settings 的 `remote-web-ui.trustRemoteHost`(手工配置优先级的兜底)。
50
+
51
+ ### 新增:设置保存改为悬浮按钮 + 二次验证
52
+
53
+ - 账号卡片里的「保存账号密码」按钮已移除;
54
+ - 插件设置子页右下角常驻「保存设置」悬浮按钮;
55
+ - 点击后弹窗验证身份,再真正保存:
56
+ - **当前密码**;或
57
+ - **SSO 验证**:`GET /dsh-webui-oauth/oidc/reauth` 走一次 IdP 授权,
58
+ 回调签发**一次性、2 分钟**的复验令牌(不新建会话),客户端存在
59
+ sessionStorage 并在保存时提交;
60
+ - `configure` / `disable` / `oidc/unbind` 均接受 `reauthToken` 替代密码;
61
+ - 令牌一次性、不可重放;密码路径保持不变。
62
+
63
+ ### 测试
64
+
65
+ - `test/oidc-bind.test.mjs` 新增 9 项:SSO 复验全链路(发起 → 回调令牌 →
66
+ 无密码保存 → 令牌不可重放)、远程信任端点(未登录 401 / 类型校验 / tapIndex 缺失降级);
67
+ - 全量 22 个测试文件、599 项断言通过。
68
+
5
69
  ## 0.7.4
6
70
 
7
71
  修正 0.7.3 对官方 DeepSeek 登录的处理:`/oauth/callback` **不再公开**,改为
package/README.en.md CHANGED
@@ -112,13 +112,16 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
112
112
  - **Remote-access trust (0.7.3, optional built-in equivalent of `dsh-remote-trust`)**: when the page is
113
113
  reached through a domain reverse proxy, the client treats it as non-loopback and Settings → Models
114
114
  reports `settings are unavailable in this browser`. Enable:
115
+ **Since 0.7.5 you can simply click "开启远程访问信任" on the settings page**; editing YAML is no longer
116
+ required (and is still supported):
115
117
  ```yaml
116
118
  remote-web-ui:
117
119
  trustRemoteHost: true
118
120
  ```
119
121
  It injects `__DSH_TRANSPORT__.ownsHost = true` into index.html through the official
120
122
  `webServer.tapIndex`; the injection is idempotent, so installing `dsh-remote-trust` as well does not
121
- duplicate it. If `tapIndex` is unavailable it only logs a warning and everything else keeps working
123
+ duplicate it. The settings toggle writes `remoteTrust` into `dsh-webui-oauth.json` and applies
124
+ dynamically (refresh the page for the injected index to take effect). If `tapIndex` is unavailable it only logs a warning and everything else keeps working
122
125
  (version compatibility).
123
126
  **Security boundary**: the declaration marks the page as a "trusted local" surface and also opens
124
127
  other client-side features gated on `isLoopback`; enable it only when the page is already protected
@@ -426,7 +429,8 @@ wired into `npm test`).
426
429
  `http://127.0.0.1:<local port>`;
427
430
  - after authorization the browser is redirected to that loopback callback — usually unreachable from
428
431
  a remote machine, but the full URL stays in the address bar;
429
- - Settings → Authentication → Remote access → "Open callback relay window", paste the URL; the plugin
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
430
434
  **replays it internally** to the account platform's registered `/oauth/callback` handler (state and
431
435
  PKCE are still validated by the platform) to finish the token exchange;
432
436
  - the relay endpoint requires the plugin session and accepts only the local port's
package/README.md CHANGED
@@ -121,11 +121,12 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
121
121
 
122
122
 
123
123
  - **远程访问信任(0.7.3,原 `dsh-remote-trust` 的可选内置)**:经域名反代访问时,客户端会把页面判为"非 loopback",设置 → 模型会报 `settings are unavailable in this browser`。开启下面这一项即可(等价于安装 `dsh-remote-trust`,但不需要额外插件):
124
+ **0.7.5 起也可以直接在设置页点「开启远程访问信任」**,无需改 YAML;手工配置仍然兼容:
124
125
  ```yaml
125
126
  remote-web-ui:
126
127
  trustRemoteHost: true
127
128
  ```
128
- 它通过官方 `webServer.tapIndex` 往 index.html 注入 `__DSH_TRANSPORT__.ownsHost = true`;注入幂等,与 `dsh-remote-trust` 同时安装也不会重复。`tapIndex` 不存在时只记一条 warn,插件其余功能照常(版本兼容)。
129
+ 它通过官方 `webServer.tapIndex` 往 index.html 注入 `__DSH_TRANSPORT__.ownsHost = true`;注入幂等,与 `dsh-remote-trust` 同时安装也不会重复。设置页开关写入插件配置 `dsh-webui-oauth.json` 的 `remoteTrust`,动态生效(刷新页面后 index 生效)。`tapIndex` 不存在时只记一条 warn,插件其余功能照常(版本兼容)。
129
130
  **安全边界**:该声明会把页面当作"可信本机",同时放开其他以 `isLoopback` 为条件的本机专属客户端表面;只应在页面已由本插件保护时启用,且服务端仍需 `--trusted-host <对外域名>`(它不改变服务端信任护栏)。
130
131
  - **追加公开路径(0.6.7,默认拒绝的配套开关)**:机器调用、无法携带浏览器会话的端点(GitHub webhook、第三方 OAuth 回跳等)必须显式声明,否则会被闸门 401。同一 settings 段:
131
132
  ```yaml
@@ -422,7 +423,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
422
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>`(显式非零端口),不支持反代域名。插件的处理方式:
423
424
  - 劫持官方登录的 `callbackOrigin`:本地回环访问保留原 origin(cookie 同 host,回调直接命中);域名访问改写成 `http://127.0.0.1:<本机端口>`;
424
425
  - 浏览器授权后会被跳到这个回环回调地址——远程机器上通常打不开,但地址栏里有完整 URL;
425
- - 设置 → 身份认证 → 远程访问 →「打开回调中转窗口」,把地址栏 URL 粘贴进去;插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
426
+ - 官方「等待登录」窗口里会直接出现一个回调粘贴框(插件用 MutationObserver 注入到该对话框内,不单独占一个悬浮入口);把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
426
427
  - 中转端点要求插件会话,且只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`(防 SSRF / 开放重定向);
427
428
  - 本机 `http://127.0.0.1:3080` 访问时官方登录原本就能直接走完,无需中转。
428
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
@@ -474,11 +474,33 @@ async function readOidcConfig(ctx, creds) {
474
474
  return { oidc: null, legacy: false }
475
475
  }
476
476
 
477
- /** 写入 OIDC 配置(独立文件,0600 由数据目录权限兜底)。 */
478
- async function writeOidcConfig(ctx, oidc) {
477
+ /**
478
+ * 读取插件配置文件的**全部**顶层字段(不止 oidc)。
479
+ * 用于在不破坏其它配置的前提下写入 remoteTrust 等新字段。
480
+ */
481
+ async function readPluginConfigFile(ctx) {
482
+ try {
483
+ const raw = await ctx.fs.readText(await ctx.fs.resolve(oidcConfigPath()))
484
+ const parsed = raw ? JSON.parse(raw) : null
485
+ return parsed && typeof parsed === 'object' ? parsed : {}
486
+ } catch (e) {
487
+ return {}
488
+ }
489
+ }
490
+
491
+ /** 合并写入插件配置文件的顶层字段,保留未提及的键。 */
492
+ async function writePluginConfigFile(ctx, patch) {
479
493
  ensureDataDir()
494
+ const current = await readPluginConfigFile(ctx)
495
+ const next = { ...current, ...patch }
480
496
  const target = await ctx.fs.resolve(oidcConfigPath())
481
- await ctx.fs.writeText(target, JSON.stringify({ v: 1, oidc }), undefined, undefined, { mode: 'danger-full-access' })
497
+ await ctx.fs.writeText(target, JSON.stringify(next), undefined, undefined, { mode: 'danger-full-access' })
498
+ return next
499
+ }
500
+
501
+ /** 写入 OIDC 配置(独立文件,0600 由数据目录权限兜底)。保留其它顶层字段。 */
502
+ async function writeOidcConfig(ctx, oidc) {
503
+ await writePluginConfigFile(ctx, { v: 1, oidc })
482
504
  }
483
505
 
484
506
  /** 从凭据对象里剥离历史遗留的 oidc 段(迁移到独立文件后不再重复保存)。 */
@@ -1104,7 +1126,9 @@ const SELF_GATED_EXACT_PATHS = new Set([
1104
1126
  '/dsh-webui-oauth/oidc/unbind',
1105
1127
  '/dsh-webui-oauth/oidc/callback',
1106
1128
  '/dsh-webui-oauth/oidc/logout',
1129
+ '/dsh-webui-oauth/oidc/reauth',
1107
1130
  '/dsh-webui-oauth/oauth-relay',
1131
+ '/dsh-webui-oauth/remote-trust',
1108
1132
  ])
1109
1133
 
1110
1134
  /**
@@ -1305,19 +1329,25 @@ export function remoteTrustEnabled(ctx) {
1305
1329
  *
1306
1330
  * @returns {boolean} 是否已安装
1307
1331
  */
1308
- export function installRemoteTrust(ctx) {
1309
- if (!remoteTrustEnabled(ctx)) return false
1332
+ export function applyRemoteTrust(ctx, enabled) {
1333
+ if (!enabled) return { ok: true, off: null }
1310
1334
  const ws = ctx && ctx.webServer
1311
1335
  if (!ws || typeof ws.tapIndex !== 'function') {
1312
- try { ctx.logger.warn('[dsh-webui-oauth] remote-web-ui.trustRemoteHost 已启用,但 webServer.tapIndex 不可用;未注入 ownsHost') } catch (e) { /* ignore */ }
1313
- return false
1336
+ try { ctx.logger.warn('[dsh-webui-oauth] 远程访问信任已启用,但 webServer.tapIndex 不可用;未注入 ownsHost') } catch (e) { /* ignore */ }
1337
+ return { ok: false, off: null }
1314
1338
  }
1315
1339
  const off = ws.tapIndex(injectRemoteTrust)
1316
1340
  try {
1317
1341
  if (typeof ctx.effect === 'function') ctx.effect(() => off, 'dsh-webui-oauth: remote trust index tap')
1318
1342
  } catch (e) { /* ignore */ }
1319
1343
  try { ctx.logger.info('[dsh-webui-oauth] remote trust index tap registered (__DSH_TRANSPORT__.ownsHost = true)') } catch (e) { /* ignore */ }
1320
- return true
1344
+ return { ok: true, off }
1345
+ }
1346
+
1347
+ /** 兼容旧调用:按 settings 配置安装一次。 */
1348
+ export function installRemoteTrust(ctx) {
1349
+ if (!remoteTrustEnabled(ctx)) return false
1350
+ return applyRemoteTrust(ctx, true).ok
1321
1351
  }
1322
1352
 
1323
1353
  /** 该请求是否是一次浏览器导航(而不是 fetch/XHR 的接口调用)。 */
@@ -2741,7 +2771,30 @@ export async function apply(ctx) {
2741
2771
  refreshSsoOnly().catch(() => {})
2742
2772
 
2743
2773
  // 远程访问信任(可选):反代域名下让设置页可用。默认关闭;tapIndex 缺失时安全降级。
2744
- installRemoteTrust(ctx)
2774
+ // 配置来源:插件配置 remoteTrust(设置页按钮写入),其次 settings 的
2775
+ // remote-web-ui.trustRemoteHost(兼容手工配置)。
2776
+ let remoteTrustOff = null
2777
+ let remoteTrustFlag = false
2778
+ const setRemoteTrustEnabled = (enabled) => {
2779
+ if (!enabled) {
2780
+ if (remoteTrustOff) { try { remoteTrustOff() } catch (e) { /* ignore */ } remoteTrustOff = null }
2781
+ remoteTrustFlag = false
2782
+ return true
2783
+ }
2784
+ if (remoteTrustOff) { remoteTrustFlag = true; return true }
2785
+ const applied = applyRemoteTrust(ctx, true)
2786
+ if (applied.ok) {
2787
+ remoteTrustOff = applied.off
2788
+ remoteTrustFlag = true
2789
+ return true
2790
+ }
2791
+ remoteTrustFlag = false
2792
+ return false
2793
+ }
2794
+ readPluginConfigFile(ctx).then((cfg) => {
2795
+ const enabled = cfg.remoteTrust === true || remoteTrustEnabled(ctx)
2796
+ setRemoteTrustEnabled(enabled)
2797
+ }).catch(() => {})
2745
2798
 
2746
2799
  // 官方 DeepSeek 账号登录:把 callbackOrigin 桥接到回环地址(远程部署由中继端点完成)。
2747
2800
  installAccountLoginBridge(ctx)
@@ -2778,12 +2831,31 @@ export async function apply(ctx) {
2778
2831
  // 授权请求状态表(TTL + 容量上限),见 makeOidcStateStore 的说明。
2779
2832
  // 必须在后台定时器使用 purge 之前定义。
2780
2833
  const oidcStateStore = makeOidcStateStore()
2834
+
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
+ }
2781
2852
  const purgeExpiredOidcStates = oidcStateStore.purge
2782
2853
 
2783
2854
  // 后台任务:过期会话清理 + enabled 状态刷新
2784
2855
  const bgTimer = setInterval(async () => {
2785
2856
  const now = Date.now()
2786
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)
2787
2859
  purgeExpiredOidcStates()
2788
2860
  try {
2789
2861
  const creds = await readCredentials(ctx)
@@ -3050,8 +3122,8 @@ export async function apply(ctx) {
3050
3122
  username: enabled ? creds.username : null,
3051
3123
  ttl: ttlOf(creds),
3052
3124
  sessionsPersisted: sessions.ok,
3053
- // 远程访问信任(可选):是否按 remote-web-ui.trustRemoteHost 注入了 ownsHost。
3054
- remoteTrust: remoteTrustEnabled(ctx),
3125
+ // 远程访问信任(可选):是否已注入 ownsHost。
3126
+ remoteTrust: remoteTrustFlag,
3055
3127
  // 核心兜底席位形态(handler | resource | absent | unknown):核心升级后
3056
3128
  // 如果这里变成 unknown,文档请求会 404 —— 排查时先看这个字段。
3057
3129
  fallback: fallbackShape(ctx.webServer),
@@ -3171,7 +3243,9 @@ export async function apply(ctx) {
3171
3243
  }
3172
3244
  }
3173
3245
  if (wasEnabled) {
3174
- const curValid = current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3246
+ // 支持 SSO 复验令牌(「仅允许 SSO」时密码登录入口不可用)
3247
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3248
+ const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3175
3249
  if (!curValid) {
3176
3250
  await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3177
3251
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3338,7 +3412,8 @@ export async function apply(ctx) {
3338
3412
  sendJson(res, 200, { ok: true })
3339
3413
  return
3340
3414
  }
3341
- const curValid = current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3415
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3416
+ const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3342
3417
  if (!curValid) {
3343
3418
  await auditLog(ctx, 'disable_failure', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3344
3419
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3474,6 +3549,39 @@ export async function apply(ctx) {
3474
3549
  },
3475
3550
  }), 'dsh-webui-oauth: oidc login')
3476
3551
 
3552
+ // OIDC 复验:GET /dsh-webui-oauth/oidc/reauth
3553
+ //
3554
+ // 用于「保存认证配置 / 禁用认证 / 解绑」前的二次验证。要求已登录会话;
3555
+ // 走一次 IdP 授权后,回调只签发一次性复验令牌(2 分钟),不新建会话。
3556
+ ctx.effect(() => ctx.webServer.register({
3557
+ kind: 'exact',
3558
+ path: '/dsh-webui-oauth/oidc/reauth',
3559
+ handler: async (req, res) => {
3560
+ try {
3561
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
3562
+ sendJson(res, 405, { error: '仅支持 GET' })
3563
+ return
3564
+ }
3565
+ if (!checkRequest(req)) {
3566
+ sendJson(res, 401, { error: 'unauthorized' })
3567
+ return
3568
+ }
3569
+ const creds0 = await readCredentials(ctx)
3570
+ if (!(await oidcEnabled(creds0))) {
3571
+ sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
3572
+ return
3573
+ }
3574
+ if (!isOidcBound(creds0)) {
3575
+ sendJson(res, 200, { ok: false, error: 'oidc-not-bound' })
3576
+ return
3577
+ }
3578
+ await startOidcAuthorize(ctx, req, res, 'reauth')
3579
+ } catch (e) {
3580
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3581
+ }
3582
+ },
3583
+ }), 'dsh-webui-oauth: oidc reauth')
3584
+
3477
3585
  // 发起 OIDC 绑定授权:GET /dsh-webui-oauth/oidc/bind
3478
3586
  //
3479
3587
  // 必须先持有有效的本地会话(已登录)——绑定语义是"把我这个管理员账号
@@ -3521,7 +3629,8 @@ export async function apply(ctx) {
3521
3629
  const creds = await readCredentials(ctx)
3522
3630
  const meta = requestMeta(req)
3523
3631
  const current = String(body.current || '')
3524
- const curValid = current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3632
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3633
+ const curValid = reauthOk || (current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3525
3634
  if (!curValid) {
3526
3635
  await auditLog(ctx, 'oidc_unbind_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3527
3636
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3671,6 +3780,25 @@ export async function apply(ctx) {
3671
3780
 
3672
3781
  // 用途分支:绑定 vs 登录。purpose 来自服务端 state 表(见 startOidcAuthorize),
3673
3782
  // 浏览器无法通过改 URL 把绑定流程降级成登录流程。
3783
+ // SSO 复验:用于保存配置/禁用/解绑的二次验证,不新建会话。
3784
+ if (st.purpose === 'reauth') {
3785
+ const bound = boundSubOf(creds)
3786
+ if (!bound) {
3787
+ await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '复验时尚未绑定身份' })
3788
+ redirectBackToApp(ctx, req, res, 'oidcerr=not-bound', clearStateCookie)
3789
+ return
3790
+ }
3791
+ if (!safeTokenEquals(String(rawSub), bound)) {
3792
+ await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '复验身份不匹配' })
3793
+ redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
3794
+ return
3795
+ }
3796
+ const token = mintReauthToken(rawSub)
3797
+ await auditLog(ctx, 'oidc_reauth_success', { username: auditName, ip: meta.ip, ua: meta.ua })
3798
+ redirectBackToApp(ctx, req, res, 'oidcreauth=' + token, clearStateCookie)
3799
+ return
3800
+ }
3801
+
3674
3802
  if (st.purpose === 'bind') {
3675
3803
  // 绑定:把本次校验通过的 sub 记进凭据文件。
3676
3804
  // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
@@ -3747,6 +3875,50 @@ export async function apply(ctx) {
3747
3875
  },
3748
3876
  }), 'dsh-webui-oauth: oidc logout')
3749
3877
 
3878
+ // 远程访问信任开关:POST /dsh-webui-oauth/remote-trust
3879
+ //
3880
+ // 设置页按钮调用。写插件配置(不碰核心 settings),并动态安装/移除 index tap。
3881
+ ctx.effect(() => ctx.webServer.register({
3882
+ kind: 'exact',
3883
+ path: '/dsh-webui-oauth/remote-trust',
3884
+ handler: async (req, res) => {
3885
+ try {
3886
+ if (req.method !== 'POST') {
3887
+ sendJson(res, 405, { error: '仅支持 POST' })
3888
+ return
3889
+ }
3890
+ if (!checkRequest(req)) {
3891
+ sendJson(res, 401, { error: 'unauthorized' })
3892
+ return
3893
+ }
3894
+ const body = await readJsonBody(req, res)
3895
+ if (body === null) return
3896
+ if (typeof body.enabled !== 'boolean') {
3897
+ sendJson(res, 200, { ok: false, error: 'invalid', reason: 'enabled 须为 boolean' })
3898
+ return
3899
+ }
3900
+ await writePluginConfigFile(ctx, { remoteTrust: body.enabled })
3901
+ const applied = setRemoteTrustEnabled(body.enabled)
3902
+ if (!applied) {
3903
+ sendJson(res, 200, {
3904
+ ok: false,
3905
+ error: 'tap-index-unavailable',
3906
+ reason: '当前核心不支持 webServer.tapIndex,无法注入 ownsHost',
3907
+ })
3908
+ return
3909
+ }
3910
+ const meta = requestMeta(req)
3911
+ await auditLog(ctx, 'remote_trust', {
3912
+ username: null, ip: meta.ip, ua: meta.ua,
3913
+ detail: body.enabled ? '已开启远程访问信任' : '已关闭远程访问信任',
3914
+ })
3915
+ sendJson(res, 200, { ok: true, enabled: remoteTrustFlag })
3916
+ } catch (e) {
3917
+ sendJson(res, 500, { error: 'remote-trust-failed' })
3918
+ }
3919
+ },
3920
+ }), 'dsh-webui-oauth: remote trust toggle')
3921
+
3750
3922
  // 官方 DeepSeek 登录的「回调中转」:POST /dsh-webui-oauth/oauth-relay
3751
3923
  //
3752
3924
  // 远程部署下,浏览器授权后会被重定向到 http://127.0.0.1:<port>/oauth/callback
package/lib/client.js CHANGED
@@ -25,6 +25,11 @@ 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-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
+ '.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); }',
30
+ '.wua-save-fab:disabled { opacity: .55; cursor: default; }',
31
+ '.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); }',
32
+ '.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); }',
28
33
  '.wua-intro { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #888); }',
29
34
  '.wua-row { display: flex; flex-direction: column; gap: 5px; }',
30
35
  '.wua-label { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); }',
@@ -60,6 +65,120 @@ window.__ModuleLoader__.load({
60
65
  if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
61
66
  }, 'dsh-webui-oauth: styles');
62
67
 
68
+ // ---------------- DeepSeek 登录回调:合并进官方「等待登录」对话框 ----------------
69
+ //
70
+ // 官方账号登录是 SignInDialog(另一个插件)渲染的,没有开放插槽;但它等待
71
+ // 浏览器时会显示一段固定文案("没有自动打开新页面?" / "Page did not open
72
+ // automatically?")。这里用 MutationObserver 找到那段文案,把回调输入框
73
+ // 插到同一个对话框里——不再单独挂一个悬浮按钮。
74
+ //
75
+ // 这是**尽力而为**的 DOM 适配:官方 UI 改版时最多退化成"不显示输入框",
76
+ // 不影响登录本身;/dsh-webui-oauth/oauth-relay 端点仍然可用。
77
+ (function mountInlineRelay() {
78
+ if (typeof document === 'undefined' || !document.body) return;
79
+ const PROMPTS = ['没有自动打开新页面', 'Page did not open automatically'];
80
+ const MARK = 'data-dsh-wua-relay-inline';
81
+ let timer = null;
82
+ const injected = new Set();
83
+
84
+ const build = () => {
85
+ const box = document.createElement('div');
86
+ box.className = 'wua-inline-relay';
87
+ box.setAttribute(MARK, '1');
88
+
89
+ const label = document.createElement('div');
90
+ label.className = 'wua-label';
91
+ label.textContent = '远程访问?把浏览器地址栏里的完整回调 URL 粘贴到这里';
92
+
93
+ const row = document.createElement('div');
94
+ row.className = 'wua-actions';
95
+ row.style.alignItems = 'stretch';
96
+ const input = document.createElement('input');
97
+ input.className = 'wua-input';
98
+ input.type = 'text';
99
+ input.placeholder = 'http://127.0.0.1:3080/oauth/callback?code=…&state=…';
100
+ input.style.flex = '1';
101
+ const submit = document.createElement('button');
102
+ submit.type = 'button';
103
+ submit.className = 'wua-btn';
104
+ submit.textContent = '完成登录';
105
+ row.appendChild(input);
106
+ row.appendChild(submit);
107
+
108
+ const msg = document.createElement('p');
109
+ msg.style.display = 'none';
110
+ box.appendChild(label);
111
+ box.appendChild(row);
112
+ box.appendChild(msg);
113
+
114
+ const show = (text, ok) => {
115
+ msg.textContent = text;
116
+ msg.className = ok ? 'wua-msg' : 'wua-err';
117
+ msg.style.display = 'block';
118
+ };
119
+ submit.addEventListener('click', () => {
120
+ const url = input.value.trim();
121
+ if (!url) { show('请粘贴完整回调地址', false); return; }
122
+ submit.disabled = true;
123
+ fetch('/dsh-webui-oauth/oauth-relay', {
124
+ method: 'POST',
125
+ headers: { 'content-type': 'application/json' },
126
+ body: JSON.stringify({ url }),
127
+ }).then((r) => {
128
+ if (r.status === 401) { location.href = '/dsh-webui-oauth/login'; return null; }
129
+ return r.json();
130
+ }).then((res) => {
131
+ submit.disabled = false;
132
+ if (!res) return;
133
+ if (res.ok) { show('已收到回调,正在完成登录…', true); input.value = ''; }
134
+ else show(res.hint || res.reason || res.error || '中转失败', false);
135
+ }).catch(() => { submit.disabled = false; show('无法连接认证服务', false); });
136
+ });
137
+ return box;
138
+ };
139
+
140
+ const scan = () => {
141
+ for (const node of injected) {
142
+ const anchor = node.__dshWuaAnchor;
143
+ const stillWaiting = anchor && anchor.isConnected &&
144
+ PROMPTS.some((p) => (anchor.textContent || '').includes(p));
145
+ if (stillWaiting) continue;
146
+ try { node.remove(); } catch (e) { /* ignore */ }
147
+ injected.delete(node);
148
+ }
149
+ const paragraphs = document.querySelectorAll('p');
150
+ for (const el of paragraphs) {
151
+ const text = (el.textContent || '').trim();
152
+ if (!PROMPTS.some((p) => text.includes(p))) continue;
153
+ const host = el.parentElement;
154
+ if (!host || host.querySelector('[' + MARK + ']')) continue;
155
+ const box = build();
156
+ box.__dshWuaAnchor = el;
157
+ host.appendChild(box);
158
+ injected.add(box);
159
+ break;
160
+ }
161
+ };
162
+
163
+ const schedule = () => {
164
+ if (timer !== null) return;
165
+ timer = setTimeout(() => {
166
+ timer = null;
167
+ try { scan(); } catch (e) { /* ignore */ }
168
+ }, 200);
169
+ };
170
+
171
+ const observer = new MutationObserver(schedule);
172
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
173
+ schedule();
174
+ ctx.effect(() => () => {
175
+ observer.disconnect();
176
+ if (timer !== null) clearTimeout(timer);
177
+ for (const node of injected) { try { node.remove(); } catch (e) { /* ignore */ } }
178
+ injected.clear();
179
+ }, 'dsh-webui-oauth: inline relay');
180
+ })();
181
+
63
182
  // ---------------- 会话失效:统一回登录页 ----------------
64
183
  const LOGIN_PATH = '/dsh-webui-oauth/login';
65
184
 
@@ -156,11 +275,15 @@ window.__ModuleLoader__.load({
156
275
  const [oidcBoundHint, setOidcBoundHint] = react.useState('');
157
276
  const [unbindPw, setUnbindPw] = react.useState('');
158
277
  const [bindMsg, setBindMsg] = react.useState(null);
159
- // 官方 DeepSeek 登录的远程回调中转:粘贴浏览器地址栏里的回调 URL。
160
- const [relayUrl, setRelayUrl] = react.useState('');
161
- const [relayMsg, setRelayMsg] = react.useState(null);
162
- const [relayBusy, setRelayBusy] = react.useState(false);
163
- const [relayOpen, setRelayOpen] = react.useState(false);
278
+ // 敏感操作复验(保存配置 / 禁用 / 解绑):密码或 SSO 一次性令牌。
279
+ const [saveOpen, setSaveOpen] = react.useState(false);
280
+ const [savePw, setSavePw] = react.useState('');
281
+ const [saveErr, setSaveErr] = react.useState('');
282
+ const [reauthToken, setReauthToken] = react.useState(null);
283
+ const [reauthExp, setReauthExp] = react.useState(0);
284
+ // 远程访问信任开关。
285
+ const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
286
+ const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
164
287
  // 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
165
288
  // redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
166
289
  const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
@@ -211,6 +334,19 @@ window.__ModuleLoader__.load({
211
334
  // 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
212
335
  // 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
213
336
  // 组件重新挂载时才知道刚才发生了什么。
337
+ // 从 sessionStorage 恢复 SSO 复验令牌(2 分钟内有效)。
338
+ react.useEffect(() => {
339
+ try {
340
+ const raw = sessionStorage.getItem('dsh_wua_reauth')
341
+ if (!raw) return
342
+ const v = JSON.parse(raw)
343
+ if (v && typeof v.token === 'string' && v.exp > Date.now()) {
344
+ setReauthToken(v.token)
345
+ setReauthExp(v.exp)
346
+ }
347
+ } catch (e) { /* ignore */ }
348
+ }, []);
349
+
214
350
  react.useEffect(() => {
215
351
  let qs = ''
216
352
  try { qs = String((location && location.search) || '') } catch (e) { qs = '' }
@@ -218,8 +354,14 @@ window.__ModuleLoader__.load({
218
354
  const p = new URLSearchParams(qs)
219
355
  const bound = p.get('oidcbound')
220
356
  const err = p.get('oidcerr')
221
- if (bound === null && err === null) return
222
- if (bound !== null) {
357
+ const reauth = p.get('oidcreauth')
358
+ if (bound === null && err === null && reauth === null) return
359
+ if (reauth !== null) {
360
+ try { sessionStorage.setItem('dsh_wua_reauth', JSON.stringify({ token: reauth, exp: Date.now() + 120000 })) } catch (e) { /* ignore */ }
361
+ setReauthToken(reauth)
362
+ setReauthExp(Date.now() + 120000)
363
+ setBindMsg({ kind: 'ok', text: 'SSO 验证成功(2 分钟内有效)。请回到设置页点击「保存设置」。' })
364
+ } else if (bound !== null) {
223
365
  setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' })
224
366
  } else {
225
367
  const LABELS = {
@@ -247,8 +389,9 @@ window.__ModuleLoader__.load({
247
389
  return { ok: true, text: '' };
248
390
  }
249
391
 
250
- const onSave = () => {
392
+ const onSave = (auth) => {
251
393
  if (busy) return;
394
+ const credentials = auth || {};
252
395
  const name = username.trim();
253
396
  // 与服务端 usernameError 一致的用户名约束
254
397
  if (!/^[A-Za-z0-9_-]{3,32}$/.test(name)) {
@@ -266,8 +409,8 @@ window.__ModuleLoader__.load({
266
409
  setMsg({ kind: 'err', text: '两次输入的密码不一致' });
267
410
  return;
268
411
  }
269
- if (enabled && !current) {
270
- setMsg({ kind: 'err', text: '修改认证信息需要输入当前密码' });
412
+ if (enabled && !credentials.current && !credentials.reauthToken) {
413
+ setSaveErr('修改认证信息需要验证:请输入当前密码,或使用 SSO 验证。');
271
414
  return;
272
415
  }
273
416
  setBusy(true);
@@ -285,7 +428,14 @@ window.__ModuleLoader__.load({
285
428
  // OIDC 关闭时不能「仅允许 SSO」,否则等于关掉所有登录入口
286
429
  ssoOnly: oidcEnabled ? oidcSsoOnly : false,
287
430
  };
288
- api('/dsh-webui-oauth/configure', { username: name, password, current, ttl: Number(ttl), oidc: oidcPayload }).then((r) => {
431
+ api('/dsh-webui-oauth/configure', {
432
+ username: name,
433
+ password,
434
+ current: credentials.current || '',
435
+ reauthToken: credentials.reauthToken || undefined,
436
+ ttl: Number(ttl),
437
+ oidc: oidcPayload,
438
+ }).then((r) => {
289
439
  setBusy(false);
290
440
  if (r && r.ok) {
291
441
  if (enabled) {
@@ -296,24 +446,37 @@ window.__ModuleLoader__.load({
296
446
  setPassword('');
297
447
  setConfirm('');
298
448
  setCurrent('');
449
+ setSaveOpen(false);
450
+ setSavePw('');
451
+ setSaveErr('');
452
+ setReauthToken(null);
453
+ setReauthExp(0);
454
+ try { sessionStorage.removeItem('dsh_wua_reauth') } catch (e) { /* ignore */ }
299
455
  api('/dsh-webui-oauth/status').then((s) => {
300
456
  if (s && !s.error) setStatus(s);
301
457
  });
302
458
  } else if (r && r.error === 'current-invalid') {
459
+ setSaveErr('当前密码不正确');
303
460
  setMsg({ kind: 'err', text: '当前密码不正确' });
304
461
  } else if (r && r.error === 'weak-password') {
305
- setMsg({ kind: 'err', text: '密码强度不足:至少 8 位,需包含大小写字母、数字和特殊符号' });
462
+ const text = '密码强度不足:至少 8 位,需包含大小写字母、数字和特殊符号';
463
+ setMsg({ kind: 'err', text }); setSaveErr(text);
306
464
  } else if (r && r.error === 'username-invalid') {
307
- setMsg({ kind: 'err', text: '用户名需为 3-32 位字母、数字、下划线或连字符' });
465
+ const text = '用户名需为 3-32 位字母、数字、下划线或连字符';
466
+ setMsg({ kind: 'err', text }); setSaveErr(text);
308
467
  } else if (r && r.error === 'legacy-hash') {
309
- setMsg({ kind: 'err', text: '当前凭据为旧版哈希(0.2.0 起仅支持 scrypt),必须填写新密码才能保存' });
468
+ const text = '当前凭据为旧版哈希(0.2.0 起仅支持 scrypt),必须填写新密码才能保存';
469
+ setMsg({ kind: 'err', text }); setSaveErr(text);
310
470
  } else if (r && (r.error === 'oidc-sso-only-unbound' || r.error === 'oidc-sso-only-invalid')) {
311
- setMsg({ kind: 'err', text: (r && r.reason) || '无法启用「仅允许 SSO」' });
471
+ const text = (r && r.reason) || '无法启用「仅允许 SSO」';
472
+ setMsg({ kind: 'err', text }); setSaveErr(text);
312
473
  } else if (r && typeof r.error === 'string') {
313
474
  // 服务端 500/未知错误:展示真实信息便于排查
314
- setMsg({ kind: 'err', text: '保存失败:' + r.error });
475
+ const text = '保存失败:' + r.error;
476
+ setMsg({ kind: 'err', text }); setSaveErr(text);
315
477
  } else {
316
- setMsg({ kind: 'err', text: '保存失败,请检查输入' });
478
+ const text = '保存失败,请检查输入';
479
+ setMsg({ kind: 'err', text }); setSaveErr(text);
317
480
  }
318
481
  }).catch(() => {
319
482
  setBusy(false);
@@ -400,6 +563,7 @@ window.__ModuleLoader__.load({
400
563
 
401
564
  const enabled = Boolean(status && status.enabled);
402
565
  const remoteTrust = Boolean(status && status.remoteTrust);
566
+ const reauthValid = Boolean(reauthToken && Date.now() < reauthExp);
403
567
  const intro = status === null
404
568
  ? '正在读取认证状态…'
405
569
  : (enabled
@@ -439,28 +603,27 @@ window.__ModuleLoader__.load({
439
603
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
440
604
  }
441
605
 
442
- const onRelay = () => {
443
- if (relayBusy) return;
444
- const url = relayUrl.trim();
445
- if (!url) {
446
- setRelayMsg({ kind: 'err', text: '请粘贴浏览器地址栏里的完整回调地址(http://127.0.0.1:<端口>/oauth/callback?...)' });
447
- return;
448
- }
449
- setRelayBusy(true);
450
- setRelayMsg(null);
451
- api('/dsh-webui-oauth/oauth-relay', { url }).then((r) => {
452
- setRelayBusy(false);
606
+ const onToggleRemoteTrust = () => {
607
+ if (remoteTrustBusy) return;
608
+ const next = !remoteTrust;
609
+ setRemoteTrustBusy(true);
610
+ setRemoteTrustMsg(null);
611
+ api('/dsh-webui-oauth/remote-trust', { enabled: next }).then((r) => {
612
+ setRemoteTrustBusy(false);
453
613
  if (r && r.ok) {
454
- setRelayMsg({ kind: 'ok', text: '已收到回调,正在完成登录。请回到「设置 → 账号」查看登录状态。' });
455
- setRelayUrl('');
456
- } else if (r && r.error === 'no-pending') {
457
- setRelayMsg({ kind: 'err', text: (r && r.hint) || '没有进行中的登录:请先在「设置 → 账号」点击 DeepSeek 登录。' });
614
+ setStatus((s) => (s ? { ...s, remoteTrust: !!r.enabled } : s));
615
+ setRemoteTrustMsg({
616
+ kind: 'ok',
617
+ text: r.enabled
618
+ ? '已开启远程访问信任。刷新页面后生效(设置 → 模型应可正常加载)。'
619
+ : '已关闭远程访问信任。刷新页面后生效。',
620
+ });
458
621
  } else {
459
- setRelayMsg({ kind: 'err', text: (r && (r.hint || r.error)) || '回调中转失败,请重试' });
622
+ setRemoteTrustMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '操作失败' });
460
623
  }
461
624
  }).catch(() => {
462
- setRelayBusy(false);
463
- setRelayMsg({ kind: 'err', text: '无法连接认证服务' });
625
+ setRemoteTrustBusy(false);
626
+ setRemoteTrustMsg({ kind: 'err', text: '无法连接认证服务' });
464
627
  });
465
628
  };
466
629
 
@@ -520,9 +683,6 @@ window.__ModuleLoader__.load({
520
683
  react.createElement('input', { className: 'wua-input', type: 'password', value: current, onChange: (e) => setCurrent(e.target.value) }),
521
684
  ) : null,
522
685
  msg ? react.createElement('div', { className: msg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, msg.text) : null,
523
- react.createElement('div', { className: 'wua-actions' },
524
- react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onSave }, busy ? '保存中…' : '保存账号密码'),
525
- ),
526
686
  ]),
527
687
  card('OIDC 单点登录', [
528
688
  oidcEnabled ? badge('已启用', 'ok') : badge('未启用', 'muted'),
@@ -616,15 +776,20 @@ window.__ModuleLoader__.load({
616
776
  ], [
617
777
  react.createElement('p', { className: 'wua-intro' },
618
778
  remoteTrust
619
- ? '已通过 index.html 注入 __DSH_TRANSPORT__.ownsHost = true,反代域名下「设置 → 模型」可正常读取。'
620
- : '若经域名反代访问时「设置 → 模型」报 settings are unavailable in this browser,请在 dsh settings 的 remote-web-ui 段设置 trustRemoteHost: true 并重启。'),
779
+ ? '已通过 index.html 注入 __DSH_TRANSPORT__.ownsHost = true,反代域名下「设置 → 模型」可正常读取。刷新页面后生效。'
780
+ : '若经域名反代访问时「设置 → 模型」报 settings are unavailable in this browser,可开启远程访问信任。'),
781
+ react.createElement('div', { className: 'wua-actions' },
782
+ react.createElement('button', {
783
+ className: 'wua-btn',
784
+ disabled: remoteTrustBusy || status === null,
785
+ onClick: onToggleRemoteTrust,
786
+ }, remoteTrustBusy ? '处理中…' : (remoteTrust ? '关闭远程访问信任' : '开启远程访问信任')),
787
+ ),
788
+ remoteTrustMsg ? react.createElement('p', { className: remoteTrustMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, remoteTrustMsg.text) : null,
621
789
  react.createElement('div', { className: 'wua-sep' }),
622
790
  react.createElement('h4', { className: 'wua-card-title' }, 'DeepSeek 官方账号登录(远程回调)'),
623
791
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
624
- '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。点下面的按钮打开中转窗口,把地址栏里的完整 URL 粘贴进去即可。'),
625
- react.createElement('div', { className: 'wua-actions' },
626
- react.createElement('button', { className: 'wua-btn', disabled: status === null, onClick: () => setRelayOpen(true) }, '打开回调中转窗口'),
627
- ),
792
+ '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
628
793
  ]),
629
794
  card('最近登录记录', [], [
630
795
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
@@ -642,16 +807,42 @@ window.__ModuleLoader__.load({
642
807
  )),
643
808
  )),
644
809
  ]),
645
- relayOpen ? react.createElement('div', { className: 'wua-float' },
646
- react.createElement('div', { className: 'wua-float-head' },
647
- react.createElement('span', { className: 'wua-float-title' }, 'DeepSeek 登录回调中转'),
648
- react.createElement('button', { className: 'wua-btn ghost', onClick: () => setRelayOpen(false) }, '关闭')),
649
- react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
650
- '把浏览器地址栏里的完整回调 URL 粘贴到这里。只接受 http://127.0.0.1 或 localhost 的本机端口地址。'),
651
- react.createElement('input', { className: 'wua-input', type: 'text', value: relayUrl, spellCheck: false, placeholder: 'http://127.0.0.1:3080/oauth/callback?code=…&state=…', onChange: (e) => setRelayUrl(e.target.value) }),
652
- react.createElement('div', { className: 'wua-actions' },
653
- react.createElement('button', { className: 'wua-btn', disabled: relayBusy, onClick: onRelay }, relayBusy ? '提交中…' : '完成登录')),
654
- relayMsg ? react.createElement('p', { className: relayMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, relayMsg.text) : null,
810
+ react.createElement('button', {
811
+ className: 'wua-save-fab',
812
+ disabled: busy || status === null,
813
+ onClick: () => {
814
+ if (!enabled) { onSave({}); return; }
815
+ if (reauthValid) { onSave({ reauthToken }); return; }
816
+ setSaveErr('');
817
+ setSaveOpen(true);
818
+ },
819
+ }, busy ? '保存中…' : '保存设置'),
820
+ saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
821
+ react.createElement('div', { className: 'wua-modal' },
822
+ react.createElement('h4', { className: 'wua-card-title' }, '验证后保存'),
823
+ react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
824
+ reauthValid
825
+ ? '已通过 SSO 验证(2 分钟内有效),可直接保存。'
826
+ : '修改认证配置需要验证当前身份:输入当前密码,或使用 SSO 验证。'),
827
+ reauthValid ? null : react.createElement('div', { className: 'wua-row' },
828
+ react.createElement('label', { className: 'wua-label' }, '当前密码'),
829
+ react.createElement('input', { className: 'wua-input', type: 'password', value: savePw, autoComplete: 'current-password', onChange: (e) => setSavePw(e.target.value) }),
830
+ ),
831
+ saveErr ? react.createElement('p', { className: 'wua-err' }, saveErr) : null,
832
+ react.createElement('div', { className: 'wua-actions' },
833
+ react.createElement('button', { className: 'wua-btn', disabled: busy, onClick: () => {
834
+ if (reauthValid) { onSave({ reauthToken }); return; }
835
+ if (!savePw) { setSaveErr('请输入当前密码'); return; }
836
+ onSave({ current: savePw });
837
+ } }, busy ? '保存中…' : '验证并保存'),
838
+ react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
839
+ ),
840
+ (status && status.oidc && status.oidc.enabled && status.oidc.bound)
841
+ ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => {
842
+ location.href = '/dsh-webui-oauth/oidc/reauth?base=' + encodeURIComponent(location.origin || '');
843
+ } }, '使用 SSO 验证')
844
+ : null,
845
+ ),
655
846
  ) : null,
656
847
  react.createElement('p', { className: 'wua-hint' },
657
848
  '密码规则:至少 8 位,必须包含大写字母、小写字母、数字和特殊符号(如 !@#$%^&*)。认证在 HTTP/传输层强制执行(WebUI 资源、/api 接口、WebSocket 全部要求有效会话),会话保存在服务端并由 HttpOnly Cookie 携带,修改密码会吊销其他所有会话。凭据以 scrypt 哈希保存在插件目录的 dsh-webui-auth.json(0.2.0 起仅接受 scrypt,旧版 SHA-256 凭据需删除该文件重新创建);忘记密码时删除该文件并重启 DSH 即可重置。可选用 OIDC 单点登录:配置 issuer/clientId/clientSecret 后登录页出现 SSO 按钮,认证通过后以 IdP 的 sub 作为本地会话用户名,并沿用相同的免登录时长机制。'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {