@allbluecn/web-app 0.23.6 → 0.23.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.
@@ -1,5 +1,16 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { isRedirect } from '@tanstack/react-router'
3
+
4
+ // 桌面判定双通道(SSR/服务端导航读 DEPLOY_TARGET,客户端导航读 desktopBridge
5
+ // 运行时事实):两者任一为真都不应把本地用户踢去 /login(桌面免登录 P0)
6
+ const H = vi.hoisted(() => ({ desktopServer: false, desktopClient: false }))
7
+
8
+ vi.mock('@/server/desktop-guest', async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import('@/server/desktop-guest')>()
10
+ return { ...actual, isDesktopServer: () => H.desktopServer }
11
+ })
12
+ vi.mock('@/lib/desktop-env', () => ({ isDesktopClient: () => H.desktopClient }))
13
+
3
14
  import { requireSessionPage } from '../page-gate'
4
15
 
5
16
  function makeArgs(session: { user?: { id?: string } } | null) {
@@ -10,6 +21,11 @@ function makeArgs(session: { user?: { id?: string } } | null) {
10
21
  }
11
22
 
12
23
  describe('requireSessionPage', () => {
24
+ beforeEach(() => {
25
+ H.desktopServer = false
26
+ H.desktopClient = false
27
+ })
28
+
13
29
  it('已登录放行(不抛错)', () => {
14
30
  expect(() => requireSessionPage(makeArgs({ user: { id: 'u1' } }))).not.toThrow()
15
31
  })
@@ -26,4 +42,24 @@ describe('requireSessionPage', () => {
26
42
  expect(options?.to).toBe('/login')
27
43
  expect(options?.search?.redirect).toBe('/settings/account')
28
44
  })
45
+
46
+ it('桌面(服务端 SSR:DEPLOY_TARGET=desktop):未登录也放行', () => {
47
+ H.desktopServer = true
48
+ expect(() => requireSessionPage(makeArgs(null))).not.toThrow()
49
+ })
50
+
51
+ it('桌面(客户端导航:desktopBridge 在场):未登录也放行', () => {
52
+ H.desktopClient = true
53
+ expect(() => requireSessionPage(makeArgs(null))).not.toThrow()
54
+ })
55
+
56
+ it('web(两通道都为假):行为不变,未登录仍跳 /login', () => {
57
+ let caught: unknown
58
+ try {
59
+ requireSessionPage(makeArgs(null))
60
+ } catch (e) {
61
+ caught = e
62
+ }
63
+ expect(isRedirect(caught as never)).toBe(true)
64
+ })
29
65
  })
@@ -1,4 +1,5 @@
1
1
  import type { AnyRouter } from '@tanstack/react-router'
2
+ import { isDesktopClient } from '@/lib/desktop-env'
2
3
 
3
4
  const UNAUTHORIZED_NAME = 'UnauthorizedError'
4
5
  const UNAUTHORIZED_STATUS = 401
@@ -30,6 +31,10 @@ export function navigateToLoginOnUnauthorized(
30
31
  router: Pick<AnyRouter, 'state' | 'navigate'>,
31
32
  ): boolean {
32
33
  if (!isUnauthorizedError(error)) return false
34
+ // 桌面端免登录:401 不等于「需要登录」(本地 guest 已是合法身份;个别端点的
35
+ // 401 是数据/云态问题),自动跳登录会把用户从正在编辑的页面踢走。登录云端
36
+ // 由用户在 settings/sync 自主发起,本兜底在桌面壳内不动作。
37
+ if (isDesktopClient()) return false
33
38
  const loc = router.state.location
34
39
  router.navigate({
35
40
  to: '/login',
@@ -1,14 +1,24 @@
1
1
  import { redirect } from '@tanstack/react-router'
2
+ import { isDesktopServer } from '@/server/desktop-guest'
3
+ import { isDesktopClient } from '@/lib/desktop-env'
2
4
 
3
5
  interface SessionLike {
4
6
  user?: { id?: string } | null
5
7
  }
6
8
 
7
- /** 「需登录页面」beforeLoad gate:未登录跳登录并携带回跳。直接用于路由 beforeLoad。 */
9
+ /**
10
+ * 「需登录页面」beforeLoad gate:未登录跳登录并携带回跳。直接用于路由 beforeLoad。
11
+ *
12
+ * 桌面端(Electron + 本地 PGlite)免登录:guest 影子会话即合法本地身份,是否
13
+ * 登录云端由用户在 settings/sync 自主决定——一律放行,不自动踢 /login。
14
+ * 判定必须同时覆盖两种执行环境:SSR/服务端导航(isDesktopServer,读
15
+ * DEPLOY_TARGET)与客户端导航(isDesktopClient,读 desktopBridge 运行时事实)。
16
+ */
8
17
  export function requireSessionPage(args: {
9
18
  context: { session: SessionLike | null }
10
19
  location: { pathname: string; searchStr: string }
11
20
  }): void {
21
+ if (isDesktopServer() || isDesktopClient()) return
12
22
  if (!args.context.session?.user?.id) {
13
23
  throw redirect({
14
24
  to: '/login',
@@ -112,7 +112,7 @@ export const SDK_GROUPS: SdkGroup[] = [
112
112
  "items": [
113
113
  {
114
114
  "name": "@allbluecn/web-app",
115
- "version": "v0.23.6",
115
+ "version": "v0.23.7",
116
116
  "lastUpdate": "Nov 1, 2026",
117
117
  "source": "apps/web/package.json",
118
118
  "docsUrl": "https://github.com/allbluecn/allblue"
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // 桌面端运行时判定(客户端安全):Electron preload 注入的 window.desktopBridge
6
+ // 是「当前页面确实跑在桌面壳里」的运行时事实。
7
+ //
8
+ // 为什么不能只用 isDesktopServer():它读 process.env.DEPLOY_TARGET,只在
9
+ // sidecar(Node 服务端)进程里成立。TanStack Router 的 beforeLoad 在**客户端
10
+ // 导航时也会重跑**,浏览器上下文没有 DEPLOY_TARGET → false → requireSessionPage
11
+ // 把桌面 guest 误判成「未登录」踢去 /login(桌面本地优先实测缺陷)。
12
+ //
13
+ // 为什么不用 isDesktopEdition()(VITE_DEPLOY_TARGET 构建期常量):那是「构建
14
+ // 目标」判定;Electron 壳以运行时 env 启动 sidecar 时 bundle 未必内联 desktop,
15
+ // 会 false negative。桥对象的判定口径与 session-guard / sync-indicator /
16
+ // cloud-login 一致(它们都以 getDesktopBridge() 为准)。
17
+ //
18
+ // SSR 恒 false(无 window):服务端侧由 isDesktopServer() 覆盖,两者在
19
+ // beforeLoad 里「服务端 SSR 放行 + 客户端导航放行」形成闭环,无 hydration 分叉。
20
+ import { getDesktopBridge } from './desktop-bridge-types'
21
+
22
+ /** 是否运行在桌面壳渲染进程(客户端);SSR / 浏览器恒 false */
23
+ export function isDesktopClient(): boolean {
24
+ return getDesktopBridge() !== null
25
+ }
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // 桌面云态登出编排(桌面本地优先 P2-A9 反向转移,从 sidebar-03/user-menu.tsx
19
+ // 抽出为可测纯编排:零 React / 零 i18n / 零 DOM 依赖,副作用全由调用方注入)。
20
+ //
21
+ // 编排顺序(裁定口径,改动需同步单测):
22
+ // ① 置转移锁(复用 bind 锁文件——引擎每 tick 判定同源,「转移进行中引擎勿 tick」;
23
+ // 旧壳无此 IPC ⇒ lock 省略,视为置锁成功,锁为双保险);
24
+ // ② transfer(serverFn reverseTransferOwnershipFn:校验 remoteAuth/会话本人 →
25
+ // 单事务 cloud→guest + outbox/row_state 同事务清空 + 重建 guest 行;事务提交后
26
+ // server 侧已清 remote-auth——崩窗口收敛);
27
+ // ③ 双会话清理:logoutRemote(幂等 re-clear remote-auth)+ clearCloudSession
28
+ // (清 cloud-session.json,引擎 guest 守卫回 idle)→ 解锁(先清云会话再解锁,
29
+ // 引擎恢复 tick 前云会话已不可用);
30
+ // ④ clearQueries + 整页 reload(reload 后 getSessionFn 回落 guest 会话)。
31
+ //
32
+ // 失败路径(保持既有语义):
33
+ // - 置锁失败(抛错或 ok=false):notify('failed') + 返回 lock-failed,不转移、不
34
+ // reload(留在云态,数据仍是云属主,可重试);
35
+ // - 转移失败:尽力解锁 + notify('failed') + 返回 transfer-failed,不清会话、不
36
+ // reload(单事务已回滚,重试幂等);
37
+ // - 会话清理/解锁失败:全部 best-effort 吞掉,**不阻塞** ④(云会话已由 serverFn
38
+ // 侧清理,解锁抛错也不阻止 reload——reload 后引擎按 guest 守卫重新判定)。
39
+
40
+ /** 用户可见提示事件(文案由调用方决定,本模块不持有 i18n) */
41
+ export type DesktopLogoutNotice = 'in-progress' | 'failed'
42
+
43
+ /** 编排终态:lock-failed / transfer-failed 表示留在云态(未 reload) */
44
+ export type DesktopLogoutStatus = 'done' | 'lock-failed' | 'transfer-failed'
45
+
46
+ export interface DesktopLogoutDeps {
47
+ /** 置转移锁(旧壳无 IPC ⇒ 省略 = 视为成功) */
48
+ lock?: () => Promise<{ ok: boolean; error?: string }>
49
+ /** 反向转移(失败必须抛出,单事务回滚语义由 serverFn 保证) */
50
+ transfer: (cloudUserId: string) => Promise<unknown>
51
+ /** 清 remote-auth(幂等 best-effort,失败不得阻塞后续) */
52
+ logoutRemote: () => Promise<unknown>
53
+ /** 清 cloud-session.json(旧壳无 IPC ⇒ 省略 = no-op) */
54
+ clearCloudSession?: () => Promise<unknown>
55
+ /** 解转移锁(失败不得阻塞后续) */
56
+ unlock?: () => Promise<unknown>
57
+ /** 清 react-query 缓存 */
58
+ clearQueries: () => void
59
+ /** 整页 reload */
60
+ reload: () => void
61
+ /** 提示回调(in-progress 在置锁前,failed 在失败路径) */
62
+ notify?: (notice: DesktopLogoutNotice) => void
63
+ }
64
+
65
+ /**
66
+ * 执行桌面云态登出编排。**调用方负责在桌面壳存在且无本地 authjs 会话时调用**
67
+ * (云态判别式),本地凭据登出仍走 signOut 链。
68
+ */
69
+ export async function runDesktopLogout(
70
+ cloudUserId: string,
71
+ deps: DesktopLogoutDeps,
72
+ ): Promise<DesktopLogoutStatus> {
73
+ deps.notify?.('in-progress')
74
+
75
+ // ① 转移锁
76
+ let lockOk = true
77
+ if (deps.lock) {
78
+ try {
79
+ lockOk = (await deps.lock()).ok
80
+ } catch {
81
+ lockOk = false
82
+ }
83
+ }
84
+ if (!lockOk) {
85
+ deps.notify?.('failed')
86
+ return 'lock-failed'
87
+ }
88
+
89
+ // ② 反向转移(失败:单事务回滚留在云态,尽力解锁后可重试)
90
+ try {
91
+ await deps.transfer(cloudUserId)
92
+ } catch {
93
+ await bestEffort(deps.unlock)
94
+ deps.notify?.('failed')
95
+ return 'transfer-failed'
96
+ }
97
+
98
+ // ③ 双会话清理 + 解锁(全部尽力而为:serverFn 已清 remote-auth,此处兜底)
99
+ await bestEffort(deps.logoutRemote)
100
+ await bestEffort(deps.clearCloudSession)
101
+ await bestEffort(deps.unlock)
102
+
103
+ // ④ 缓存清空 + 整页刷新(reload 后 desktop-state 回 guest)
104
+ deps.clearQueries()
105
+ deps.reload()
106
+ return 'done'
107
+ }
108
+
109
+ /** best-effort:缺省跳过、抛错吞掉(失败路径不得阻塞主流程) */
110
+ async function bestEffort(action?: () => Promise<unknown>): Promise<void> {
111
+ if (!action) return
112
+ try {
113
+ await action()
114
+ } catch {
115
+ /* 尽力而为 */
116
+ }
117
+ }
@@ -22,6 +22,11 @@ import { getAuthConfig } from './auth/auth.config'
22
22
  // desktop-session-merge 为纯数据组装 + remote-auth 纯 fs 读取(node:fs +
23
23
  // env 守卫),无 prisma/server 重依赖链,客户端/云端部署零副作用。
24
24
  import { getDesktopRemoteSession } from '@/server/desktop-session-merge'
25
+ // 桌面免登录(本地优先 P0 修复):guest 影子身份是本地合法身份,必须在**鉴权
26
+ // 单点**生效——requireAuth / server seam / 各 serverFn 的软鉴权(getAccountTeamFn、
27
+ // design/detail 等)全部复用本函数,只在这里补一次即可让全站口径一致;否则
28
+ // 这些端点把 guest 判成未登录,路由 beforeLoad 收到 unauthorized 后踢 /login。
29
+ import { buildGuestSession, isDesktopServer } from '@/server/desktop-guest'
25
30
 
26
31
  export type SessionUser = NonNullable<AuthSession['user']> & {
27
32
  id?: string
@@ -43,6 +48,51 @@ export type SessionUser = NonNullable<AuthSession['user']> & {
43
48
 
44
49
  export type Session = Omit<AuthSession, 'user'> & { user?: SessionUser | null }
45
50
 
51
+ /** desktop guest 降级的 warn-once 标记(进程级;沿用 requireAuth 的排查线索语义:
52
+ * 桌面单机 guest 合法,但服务器部署误设 DEPLOY_TARGET=desktop 时匿名请求会变成
53
+ * 共享 guest 身份(fail-open)——至少留一条日志) */
54
+ let warnedDesktopGuest = false
55
+
56
+ /** fail-open 安全自检的 warn/error-once 标记(进程级) */
57
+ let warnedDesktopSecurity = false
58
+
59
+ /**
60
+ * 桌面回落入口的安全自检(进程级一次)。
61
+ *
62
+ * `DEPLOY_TARGET=desktop` 在本仓是**安全边界**而不只是功能开关:它让匿名请求
63
+ * 变成共享的本地 guest 身份(AI / SSE / anyhub 文件代理等全部 getCurrentSession
64
+ * 消费点一起生效)。真正的桌面后端由 Electron 主进程注入 `DESKTOP_USER_DATA_DIR`
65
+ * (PGlite 数据目录 + remote-auth.json 的根)——该 env 缺失而 DEPLOY_TARGET 仍是
66
+ * desktop,最可能的解释是「云部署误设了 DEPLOY_TARGET」。
67
+ *
68
+ * 当前口径:**只 console.error 告警,不硬失败**(开发/测试也放行)。生产环境是否
69
+ * 直接拒绝启动/拒绝 guest 回落由控制者另行裁决;在此之前保留可用的同时留下显眼
70
+ * 线索。硬失败点应加在本函数之后(返回 guest 之前)。
71
+ */
72
+ function checkDesktopFallbackEnvOnce(): void {
73
+ if (warnedDesktopSecurity) return
74
+ warnedDesktopSecurity = true
75
+ if (process.env.DESKTOP_USER_DATA_DIR) return
76
+ console.error(
77
+ '[session] 安全告警:DEPLOY_TARGET=desktop 但未设置 DESKTOP_USER_DATA_DIR。\n' +
78
+ ' DEPLOY_TARGET=desktop 是安全边界:匿名请求会被当作本地 guest 身份(AI / SSE / 文件代理等全部放行)。\n' +
79
+ ' 若非桌面单机部署,请立即移除 DEPLOY_TARGET=desktop 以恢复鉴权;\n' +
80
+ ' 桌面壳会由 Electron 主进程注入 DESKTOP_USER_DATA_DIR(PGlite 目录 / remote-auth.json 根)。',
81
+ )
82
+ }
83
+
84
+ function guestSession() {
85
+ checkDesktopFallbackEnvOnce()
86
+ if (!warnedDesktopGuest) {
87
+ warnedDesktopGuest = true
88
+ console.warn(
89
+ '[session] DEPLOY_TARGET=desktop:无会话请求已回落到本地 guest 身份。' +
90
+ '若这是服务器/云端部署,请移除 DEPLOY_TARGET=desktop 以恢复鉴权。',
91
+ )
92
+ }
93
+ return buildGuestSession()
94
+ }
95
+
46
96
  export async function getCurrentSession(request?: Request): Promise<Session | null> {
47
97
  if (!request) return null
48
98
  try {
@@ -56,12 +106,32 @@ export async function getCurrentSession(request?: Request): Promise<Session | nu
56
106
  // 604 处自动受益;非 desktop 恒 null(desktop 判定先于 fs 读取,零 IO)。
57
107
  const remoteSession = await getDesktopRemoteSession()
58
108
  if (remoteSession) return remoteSession
109
+ // 桌面免登录(本地优先 P0):既无 cloud 身份也未登录时,guest 影子用户即
110
+ // 本机当前用户(与 getSessionFn / requireAuth 的 guest 分支同一身份)。
111
+ // 放在本单点而非各端点:否则 account-team / design-detail 等「软鉴权」端点
112
+ // 把 guest 判成未登录 → beforeLoad unauthorized → 自动跳 /login(实测 P0)。
113
+ if (isDesktopServer()) return guestSession()
59
114
  return session
60
115
  } catch (error) {
61
116
  // JWT 解密失败(AUTH_SECRET 轮换 / 跨应用同名 cookie 污染)等效于无 session:
62
117
  // 返回 null 走未登录分支;@auth/core 会自行清理坏 cookie。
63
118
  // 不兜底时 JWTSessionError 会炸穿所有未捕获的调用点(route beforeLoad / serverFn)。
64
119
  console.warn('[session] 读取 session 失败(坏 cookie 或 auth 配置异常),降级为未登录:', error instanceof Error ? error.message : error)
120
+ // 桌面端不存在「未登录」态:坏 cookie / 鉴权配置异常不得让本地用户掉线
121
+ // (否则首屏 session 为 null → 页面守卫与控制台全是登录引导)。
122
+ //
123
+ // 但「authjs 抛错」≠「本机无身份」:桌面 cloud 态的真身份在 remote-auth.json,
124
+ // 必须先试云身份再回落 guest——直接回 guest 会让云账号用户看起来「数据全空」,
125
+ // 期间写入还会落到 local-guest 属主(数据错主,后续反向转移也救不回来)。
126
+ if (isDesktopServer()) {
127
+ try {
128
+ const remoteSession = await getDesktopRemoteSession()
129
+ if (remoteSession) return remoteSession
130
+ } catch (remoteError) {
131
+ console.warn('[session] 桌面云身份回落失败,降级本地 guest:', remoteError instanceof Error ? remoteError.message : remoteError)
132
+ }
133
+ return guestSession()
134
+ }
65
135
  return null
66
136
  }
67
137
  }
@@ -15,7 +15,7 @@ async function importIndexRoute(profileId: 'oss' | 'saas', desktop = false) {
15
15
  return import('../_public/index/route')
16
16
  }
17
17
 
18
- async function importNavRoute(opts: { profileId: 'oss' | 'saas'; desktop: boolean }) {
18
+ async function importNavRoute(opts: { profileId: 'oss' | 'saas'; desktop: boolean; desktopClient?: boolean }) {
19
19
  vi.resetModules()
20
20
  vi.doMock('@/plugins/modules.gen', () => ({ profileId: opts.profileId }))
21
21
  // 部分 mock:保留真实 GUEST_USER_ID 等导出——source-mode 键隔离引入了
@@ -24,6 +24,9 @@ async function importNavRoute(opts: { profileId: 'oss' | 'saas'; desktop: boolea
24
24
  const actual = await importOriginal<typeof import('@/server/desktop-guest')>()
25
25
  return { ...actual, isDesktopServer: () => opts.desktop }
26
26
  })
27
+ // 客户端导航判定(desktopBridge 运行时事实):桌面壳里 process.env 无
28
+ // DEPLOY_TARGET,仅靠 isDesktopServer 会在浏览器 side 失守
29
+ vi.doMock('@/lib/desktop-env', () => ({ isDesktopClient: () => opts.desktopClient === true }))
27
30
  return import('../_nav/route')
28
31
  }
29
32
 
@@ -115,6 +118,13 @@ describe('_nav beforeLoad', () => {
115
118
  catchRedirect(() => Route.options.beforeLoad!({ context: { session: null }, location } as never)),
116
119
  ).toBeNull()
117
120
  })
121
+
122
+ it('桌面客户端导航(无 DEPLOY_TARGET,desktopBridge 在场):同样无条件放行', async () => {
123
+ const { Route } = await importNavRoute({ profileId: 'saas', desktop: false, desktopClient: true })
124
+ expect(
125
+ catchRedirect(() => Route.options.beforeLoad!({ context: { session: null }, location } as never)),
126
+ ).toBeNull()
127
+ })
118
128
  })
119
129
 
120
130
  describe('GlobalErrorBoundary', () => {
@@ -146,11 +156,17 @@ describe('GlobalErrorBoundary', () => {
146
156
  })
147
157
 
148
158
  describe('需登录页面 gate', () => {
149
- it.each([
150
- ['../_nav/settings/route', '/settings/general'],
151
- ['../_nav/notifications/route', '/notifications'],
152
- ['../_nav/workspaces/route', '/workspaces'],
153
- ])('%s 未登录跳 /login', async (mod, pathname) => {
159
+ // 本 describe 的桌面判定统一钉死(前面的 importNavRoute 会注册 doMock,
160
+ // vi.doMock 跨 resetModules 持久,不显式覆盖会串味)
161
+ async function mockDesktop(opts: { desktopServer: boolean; desktopClient: boolean }) {
162
+ vi.doMock('@/server/desktop-guest', async (importOriginal) => {
163
+ const actual = await importOriginal<typeof import('@/server/desktop-guest')>()
164
+ return { ...actual, isDesktopServer: () => opts.desktopServer }
165
+ })
166
+ vi.doMock('@/lib/desktop-env', () => ({ isDesktopClient: () => opts.desktopClient }))
167
+ }
168
+
169
+ async function prepareRoute(mod: string, desktop: { desktopServer: boolean; desktopClient: boolean }) {
154
170
  vi.resetModules()
155
171
  vi.doMock('@/plugins/modules.gen', () => ({ profileId: 'oss' }))
156
172
  vi.doMock('@/lib/i18n/server', () => ({ createServerI18n: () => ({ t: (k: string) => k }) }))
@@ -162,7 +178,16 @@ describe('需登录页面 gate', () => {
162
178
  NOTIFICATION_LIST_KEY: ['notifications', 'list'],
163
179
  NOTIFICATION_UNREAD_KEY: ['notifications', 'unread'],
164
180
  }))
165
- const { Route } = await import(mod)
181
+ await mockDesktop(desktop)
182
+ return import(mod)
183
+ }
184
+
185
+ it.each([
186
+ ['../_nav/settings/route', '/settings/general'],
187
+ ['../_nav/notifications/route', '/notifications'],
188
+ ['../_nav/workspaces/route', '/workspaces'],
189
+ ])('%s 未登录跳 /login', async (mod, pathname) => {
190
+ const { Route } = await prepareRoute(mod, { desktopServer: false, desktopClient: false })
166
191
  const r = catchRedirect(() =>
167
192
  Route.options.beforeLoad!({
168
193
  context: { session: null },
@@ -172,4 +197,20 @@ describe('需登录页面 gate', () => {
172
197
  expect(r?.to).toBe('/login')
173
198
  expect(r?.search?.redirect).toBe(pathname)
174
199
  })
200
+
201
+ it.each([
202
+ ['../_nav/settings/route', '/settings/general'],
203
+ ['../_nav/notifications/route', '/notifications'],
204
+ ['../_nav/workspaces/route', '/workspaces'],
205
+ ])('%s 桌面客户端(desktopBridge 在场)未登录也放行', async (mod, pathname) => {
206
+ const { Route } = await prepareRoute(mod, { desktopServer: false, desktopClient: true })
207
+ expect(
208
+ catchRedirect(() =>
209
+ Route.options.beforeLoad!({
210
+ context: { session: null },
211
+ location: { pathname, searchStr: '' },
212
+ } as never),
213
+ ),
214
+ ).toBeNull()
215
+ })
175
216
  })
@@ -0,0 +1,99 @@
1
+ // 桌面端登录页不得跳管理后台 setup(实测 P0:打开 /login 被重定向到
2
+ // http://localhost:3001/setup)。桌面没有管理员概念(本地 guest + 云端账号),
3
+ // 该跳转是 admin 应用首启向导的死链。
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ const H = vi.hoisted(() => ({
7
+ checkAdminExists: vi.fn(async () => ({ hasAdmin: false })),
8
+ desktopServer: true,
9
+ desktopClient: false,
10
+ }))
11
+
12
+ vi.mock('@/server/serverFns/auth/setup', () => ({
13
+ checkAdminExistsFn: () => H.checkAdminExists(),
14
+ }))
15
+ // 登录页 loader 依赖:无需真实 provider 解析(会触 prisma)
16
+ vi.mock('@/server/serverFns/auth/providers', () => ({
17
+ getAuthProviders: vi.fn(async () => ({ googleEnabled: false })),
18
+ }))
19
+
20
+ async function importLoginRoute(opts: { desktopServer?: boolean; desktopClient?: boolean } = {}) {
21
+ vi.resetModules()
22
+ H.desktopServer = opts.desktopServer ?? false
23
+ H.desktopClient = opts.desktopClient ?? false
24
+ vi.doMock('@/server/desktop-guest', async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import('@/server/desktop-guest')>()
26
+ return { ...actual, isDesktopServer: () => H.desktopServer }
27
+ })
28
+ // 客户端导航通道(desktopBridge 运行时事实):beforeLoad 在浏览器重跑时
29
+ // process.env.DEPLOY_TARGET 不存在,只有这一条能挡住 admin setup 跳转
30
+ vi.doMock('@/lib/desktop-env', () => ({ isDesktopClient: () => H.desktopClient }))
31
+ return import('../_login/login/route')
32
+ }
33
+
34
+ /** 跑 async beforeLoad,返回 redirect 目标(无跳转 → null) */
35
+ async function runBeforeLoad(
36
+ Route: Awaited<ReturnType<typeof importLoginRoute>>['Route'],
37
+ ): Promise<{ href?: string; to?: string } | null> {
38
+ try {
39
+ await (Route.options.beforeLoad as unknown as (ctx: unknown) => Promise<void>)({})
40
+ return null
41
+ } catch (e) {
42
+ return (e as { options?: { href?: string; to?: string } }).options ?? {}
43
+ }
44
+ }
45
+
46
+ describe('/login beforeLoad 的 admin 首启向导跳转', () => {
47
+ afterEach(() => {
48
+ vi.unstubAllEnvs()
49
+ vi.clearAllMocks()
50
+ })
51
+
52
+ it('桌面端(服务端 SSR):hasAdmin=false 也不跳 setup(且不发起 admin 检测 RPC)', async () => {
53
+ H.checkAdminExists.mockResolvedValue({ hasAdmin: false })
54
+ vi.stubEnv('ADMIN_URL', 'http://admin.example:9999')
55
+
56
+ const { Route } = await importLoginRoute({ desktopServer: true })
57
+ const redirectTarget = await runBeforeLoad(Route)
58
+
59
+ expect(redirectTarget).toBeNull()
60
+ expect(H.checkAdminExists).not.toHaveBeenCalled()
61
+ })
62
+
63
+ it('桌面端(客户端导航:仅 desktopBridge 在场、无 DEPLOY_TARGET)同样不跳 setup', async () => {
64
+ // K1:beforeLoad 会在浏览器重跑,此时 isDesktopServer 为 false——只靠服务端
65
+ // 通道会让「本地会话退出 / 注册重置回跳 / 错误页登录链接」再次被送去 setup
66
+ H.checkAdminExists.mockResolvedValue({ hasAdmin: false })
67
+ vi.stubEnv('ADMIN_URL', 'http://admin.example:9999')
68
+
69
+ const { Route } = await importLoginRoute({ desktopServer: false, desktopClient: true })
70
+ const redirectTarget = await runBeforeLoad(Route)
71
+
72
+ expect(redirectTarget).toBeNull()
73
+ expect(H.checkAdminExists).not.toHaveBeenCalled()
74
+ })
75
+
76
+ it('web 端:hasAdmin=false 仍跳 ADMIN_URL/setup(既有行为不回归)', async () => {
77
+ H.checkAdminExists.mockResolvedValue({ hasAdmin: false })
78
+ vi.stubEnv('ADMIN_URL', 'http://admin.example:9999')
79
+
80
+ const { Route } = await importLoginRoute()
81
+ const redirectTarget = await runBeforeLoad(Route)
82
+
83
+ expect(redirectTarget?.href).toBe('http://admin.example:9999/setup')
84
+ })
85
+
86
+ it('web 端:admin 已初始化则放行', async () => {
87
+ H.checkAdminExists.mockResolvedValue({ hasAdmin: true })
88
+
89
+ const { Route } = await importLoginRoute()
90
+ expect(await runBeforeLoad(Route)).toBeNull()
91
+ })
92
+
93
+ it('web 端:admin 检测失败(RPC 抛错)时放行不阻断登录', async () => {
94
+ H.checkAdminExists.mockRejectedValue(new Error('rpc down'))
95
+
96
+ const { Route } = await importLoginRoute()
97
+ expect(await runBeforeLoad(Route)).toBeNull()
98
+ })
99
+ })
@@ -19,6 +19,8 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
19
19
  import { z } from 'zod'
20
20
  import { getAuthProviders } from '@/server/serverFns/auth/providers'
21
21
  import { checkAdminExistsFn } from '@/server/serverFns/auth/setup'
22
+ import { isDesktopServer } from '@/server/desktop-guest'
23
+ import { isDesktopClient } from '@/lib/desktop-env'
22
24
 
23
25
  export const Route = createFileRoute('/_login/login')({
24
26
  validateSearch: z.object({
@@ -28,9 +30,22 @@ export const Route = createFileRoute('/_login/login')({
28
30
  ssoError: z.string().optional(),
29
31
  }),
30
32
  beforeLoad: async () => {
31
- // env 读取必须在 beforeLoad 内(服务端执行):路由模块会被客户端加载,
32
- // 顶层读 process.env 在浏览器 chunk 求值时直接 ReferenceError
33
- const adminUrl = process.env.ADMIN_URL || 'http://localhost:3001'
33
+ // 桌面端(Electron + 本地 PGlite)没有「管理员/首启向导」概念:本地是
34
+ // guest 影子身份、云端账号经 settings/sync 的 DesktopCloudLogin 激活。
35
+ // /setup 是 admin 应用(默认 localhost:3001)的首启向导,桌面壳里既没有
36
+ // 该服务、也没有可初始化超管,跳过去必是死链/连接失败(实测 P0:打开
37
+ // /login 直接被重定向到 http://localhost:3001/setup)。
38
+ //
39
+ // 判定必须双通道:beforeLoad 会在**客户端导航时重跑**,浏览器里
40
+ // process.env.DEPLOY_TARGET 不存在(isDesktopServer 为 false),只有
41
+ // desktopBridge 运行时事实(isDesktopClient)能挡住——否则从本地凭据退出、
42
+ // 注册/重置成功回跳、错误页/邀请页的登录链接都会再次被送去 admin setup。
43
+ if (isDesktopServer() || isDesktopClient()) return
44
+ // 以下只在非桌面路径求值:桌面在上一行已返回,不会读 env、也不会发 RPC。
45
+ // env 读取必须留在 beforeLoad 内(不能提到模块顶层:路由模块会被客户端加载,
46
+ // 顶层读 process.env 在浏览器 chunk 求值时直接 ReferenceError);此处再补
47
+ // typeof 守卫,防非桌面客户端导航时浏览器无 Node process。
48
+ const adminUrl = (typeof process !== 'undefined' ? process.env.ADMIN_URL : undefined) || 'http://localhost:3001'
34
49
  const result = await checkAdminExistsFn().catch(() => null)
35
50
  if (result?.hasAdmin === false) {
36
51
  throw redirect({ href: `${adminUrl}/setup` })
@@ -17,12 +17,17 @@
17
17
 
18
18
  import { createFileRoute, notFound, redirect } from '@tanstack/react-router'
19
19
  import { getDesignDetailFn } from '@/server/serverFns/design/detail'
20
+ import { isDesktopServer } from '@/server/desktop-guest'
21
+ import { isDesktopClient } from '@/lib/desktop-env'
20
22
 
21
23
  export const Route = createFileRoute('/_nav/design/canvas/$id')({
22
24
  beforeLoad: async (ctx) => {
23
25
  const id = ctx.params.id
24
26
  const result = await getDesignDetailFn({ data: { id } })
25
27
  if (result.error === 'unauthorized') {
28
+ // 桌面端免登录:不自动跳 /login(guest 已是本地身份,走到这里属异常态),
29
+ // 降级为 notFound 页,登录与否由用户自主决定
30
+ if (isDesktopServer() || isDesktopClient()) throw notFound()
26
31
  throw redirect({ to: '/login' })
27
32
  }
28
33
  if (result.error === 'not_found' || !result.prd) {
@@ -17,6 +17,8 @@
17
17
 
18
18
  import { createFileRoute, redirect } from '@tanstack/react-router'
19
19
  import { getKnowledgeDetailFn, listDocumentsFn } from '@/server/serverFns/knowledge'
20
+ import { isDesktopServer } from '@/server/desktop-guest'
21
+ import { isDesktopClient } from '@/lib/desktop-env'
20
22
  import { KnowledgeWorkspacePending } from './-components/knowledge-workspace-pending'
21
23
 
22
24
  export const Route = createFileRoute('/_nav/knowledge/$kbId')({
@@ -24,8 +26,10 @@ export const Route = createFileRoute('/_nav/knowledge/$kbId')({
24
26
  pendingComponent: KnowledgeWorkspacePending,
25
27
  beforeLoad: async ({ context, location, params }) => {
26
28
  // 会话守卫:ssr:false 路由的 beforeLoad 在客户端导航也会执行,
27
- // 覆盖 _nav 守卫不重跑(父路由已匹配)时 session 过期的窗口
28
- if (!context.session?.user?.id) {
29
+ // 覆盖 _nav 守卫不重跑(父路由已匹配)时 session 过期的窗口。
30
+ // 桌面端免登录(server/client 双判):本地 guest 即身份,不自动跳 /login,
31
+ // 缺数据时降级为下方「知识库不存在」分支,登录由用户自主发起。
32
+ if (!context.session?.user?.id && !isDesktopServer() && !isDesktopClient()) {
29
33
  throw redirect({
30
34
  to: '/login',
31
35
  search: { redirect: location.pathname + location.searchStr },
@@ -18,6 +18,7 @@
18
18
  import { createFileRoute } from '@tanstack/react-router'
19
19
 
20
20
  import { isDesktopServer } from '@/server/desktop-guest'
21
+ import { isDesktopClient } from '@/lib/desktop-env'
21
22
  import { requireSessionPage } from '@/lib/auth/page-gate'
22
23
  import { listPinnedWorkspacesBriefFn } from '@/server/serverFns/workspace'
23
24
  import { listKnowledgeFn } from '@/server/serverFns/knowledge'
@@ -29,7 +30,10 @@ import { NavNotFound } from './-components/nav-not-found'
29
30
 
30
31
  export const Route = createFileRoute('/_nav')({
31
32
  beforeLoad: ({ context, location }) => {
32
- if (isDesktopServer()) return
33
+ // 桌面端免登录:isDesktopServer 只在 SSR/sidecar 成立,客户端导航(beforeLoad
34
+ // 会在浏览器重跑)必须靠 desktopBridge 运行时事实判定,否则 guest 被误判
35
+ // 未登录踢去 /login(实测 P0;用户要求「不自动跳转登录,自主决定」)
36
+ if (isDesktopServer() || isDesktopClient()) return
33
37
  requireSessionPage({ context, location })
34
38
  },
35
39
  loader: async ({ context }) => {