@allbluecn/web-app 0.23.5 → 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.
- package/package.json +1 -1
- package/src/components/desktop-agent/agent-chat.tsx +3 -2
- package/src/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
- package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
- package/src/components/shared-kit/auth/auth-client.ts +4 -0
- package/src/lib/__tests__/desktop-logout.test.ts +239 -0
- package/src/lib/__tests__/session.test.ts +85 -2
- package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
- package/src/lib/auth/anonymous-safe.ts +5 -0
- package/src/lib/auth/page-gate.ts +11 -1
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-agent/__tests__/connection.test.ts +93 -6
- package/src/lib/desktop-agent/connection.ts +36 -15
- package/src/lib/desktop-bridge-types.ts +12 -1
- package/src/lib/desktop-env.ts +25 -0
- package/src/lib/desktop-logout.ts +117 -0
- package/src/lib/session.ts +70 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
- package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
- package/src/routes/_login/login/route.tsx +18 -3
- package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
- package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
- package/src/routes/_nav/route.tsx +5 -1
- package/src/routes/_nav/settings/account/route.tsx +5 -0
- package/src/routes/_nav/settings/team/route.tsx +5 -0
- package/src/routes/api/auth/$.ts +87 -2
- package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
- package/src/routes/api/sync/__tests__/bootstrap.test.ts +67 -0
- package/src/routes/api/sync/__tests__/pull.test.ts +68 -0
- package/src/routes/api/sync/__tests__/push.test.ts +69 -0
- package/src/routes/api/sync/__tests__/snapshot.test.ts +66 -0
- package/src/routes/api/sync/bootstrap.ts +20 -15
- package/src/routes/api/sync/pull.ts +62 -57
- package/src/routes/api/sync/push.ts +63 -58
- package/src/routes/api/sync/snapshot.ts +71 -66
- package/src/server/desktop-guest.ts +5 -1
- package/src/server/serverFns/auth/session.ts +19 -10
- package/src/server/sync/http-errors.ts +43 -0
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }) => {
|
|
@@ -19,6 +19,8 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
|
19
19
|
import { getAccountTeamFn } from '@/server/serverFns/settings/account-team'
|
|
20
20
|
import type { AvatarConfig } from '@allbluecn/kernel'
|
|
21
21
|
import { toAvatarShape } from '@/components/shared-kit/avatars/avatar-shape'
|
|
22
|
+
import { isDesktopServer } from '@/server/desktop-guest'
|
|
23
|
+
import { isDesktopClient } from '@/lib/desktop-env'
|
|
22
24
|
|
|
23
25
|
export const Route = createFileRoute('/_nav/settings/account')({
|
|
24
26
|
beforeLoad: async () => {
|
|
@@ -32,6 +34,9 @@ export const Route = createFileRoute('/_nav/settings/account')({
|
|
|
32
34
|
}
|
|
33
35
|
// 未登录走框架跳转(beforeLoad + redirect),不再下发错误状态由组件二次判断
|
|
34
36
|
if (result.error === 'unauthorized') {
|
|
37
|
+
// 桌面端免登录:不自动跳 /login(本地 guest 已就绪时这里是异常态),
|
|
38
|
+
// 降级为页面错误态,登录与否由用户自主决定
|
|
39
|
+
if (isDesktopServer() || isDesktopClient()) return { error: 'error' as const }
|
|
35
40
|
throw redirect({ to: '/login' })
|
|
36
41
|
}
|
|
37
42
|
return {
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
19
19
|
import { getAccountTeamFn } from '@/server/serverFns/settings/account-team'
|
|
20
|
+
import { isDesktopServer } from '@/server/desktop-guest'
|
|
21
|
+
import { isDesktopClient } from '@/lib/desktop-env'
|
|
20
22
|
|
|
21
23
|
export const Route = createFileRoute('/_nav/settings/team')({
|
|
22
24
|
beforeLoad: async () => {
|
|
@@ -29,6 +31,9 @@ export const Route = createFileRoute('/_nav/settings/team')({
|
|
|
29
31
|
}
|
|
30
32
|
// 未登录走框架跳转(beforeLoad + redirect),不再下发错误状态由组件二次判断
|
|
31
33
|
if (result.error === 'unauthorized') {
|
|
34
|
+
// 桌面端免登录:不自动跳 /login(本地 guest 已就绪时这里是异常态),
|
|
35
|
+
// 降级为页面错误态,登录与否由用户自主决定
|
|
36
|
+
if (isDesktopServer() || isDesktopClient()) return { error: 'error' as const }
|
|
32
37
|
throw redirect({ to: '/login' })
|
|
33
38
|
}
|
|
34
39
|
return {
|
package/src/routes/api/auth/$.ts
CHANGED
|
@@ -16,13 +16,98 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
import { createFileRoute } from '@tanstack/react-router'
|
|
19
|
-
import { getAuthConfig } from '@/lib/auth/auth.config'
|
|
20
19
|
import { StartAuthJS } from 'start-authjs'
|
|
20
|
+
import { getAuthConfig } from '@/lib/auth/auth.config'
|
|
21
|
+
import { getCurrentSession } from '@/lib/session'
|
|
22
|
+
import { GUEST_USER_ID, isDesktopServer } from '@/server/desktop-guest'
|
|
23
|
+
import { ensureLocalShadowUser } from '@/server/desktop-bootstrap'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 桌面端「我是谁」端点回落(本地优先 P0 修复):
|
|
27
|
+
*
|
|
28
|
+
* 客户端 useSession()(components/shared-kit/auth/auth-client.ts)打的就是
|
|
29
|
+
* /api/auth/session。authjs 只认自己的 JWT cookie —— 桌面壳里没有(本地免登录
|
|
30
|
+
* + 云端身份在 remote-auth.json),于是恒返回 null,所有以 session 判登录态的
|
|
31
|
+
* 组件(user-menu / collab gate / 评论署名 / AI 面板等)都把本地用户当匿名。
|
|
32
|
+
*
|
|
33
|
+
* 修法(服务端单点,覆盖面最大):仅拦截 GET /api/auth/session,authjs 无会话
|
|
34
|
+
* 且为桌面部署时回落 getCurrentSession(鉴权单点:authjs → remote-auth 云身份
|
|
35
|
+
* → guest 影子用户)。其它路径(csrf/providers/signin/callback/...)与 POST
|
|
36
|
+
* 原样透传,登录/登出语义零改动。
|
|
37
|
+
*
|
|
38
|
+
* 安全:/api/auth/session 本就是会话自述端点,返回当前(可能 guest/云)身份
|
|
39
|
+
* 不扩大暴露面;Session 结构里没有 token/密钥。非桌面部署行为完全不变。
|
|
40
|
+
*
|
|
41
|
+
* guest 分支附带「懒 seed 影子用户」(幂等 upsert,失败仅 warn):端点可能在
|
|
42
|
+
* root beforeLoad 的 getSessionFn 之前/失败时被调用,不 seed 就会下发一个 DB
|
|
43
|
+
* 里不存在的身份,随后写入撞 FK(P2003)。
|
|
44
|
+
*/
|
|
45
|
+
function isDesktopSessionEndpoint(request: Request): boolean {
|
|
46
|
+
if (!isDesktopServer()) return false
|
|
47
|
+
const pathname = new URL(request.url).pathname.replace(/\/+$/, '')
|
|
48
|
+
return pathname === '/api/auth/session'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 透传 authjs 的 Set-Cookie(坏 cookie 清理头),只替换 body */
|
|
52
|
+
function rebuildSessionResponse(response: Response, payload: unknown): Response {
|
|
53
|
+
const headers = new Headers()
|
|
54
|
+
// undici 实现才有 getSetCookie;类型上做可选收窄,其它运行时退化为无 cookie 透传
|
|
55
|
+
const setCookies = (response.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.() ?? []
|
|
56
|
+
for (const cookie of setCookies) headers.append('set-cookie', cookie)
|
|
57
|
+
headers.set('content-type', 'application/json')
|
|
58
|
+
headers.set('cache-control', 'no-store')
|
|
59
|
+
return new Response(JSON.stringify(payload), {
|
|
60
|
+
status: response.status,
|
|
61
|
+
statusText: response.statusText,
|
|
62
|
+
headers,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
21
65
|
|
|
22
66
|
export const Route = createFileRoute('/api/auth/$')({
|
|
23
67
|
server: {
|
|
24
68
|
handlers: {
|
|
25
|
-
GET: async ({ request }) =>
|
|
69
|
+
GET: async ({ request }) => {
|
|
70
|
+
const response = await (await StartAuthJS(await getAuthConfig())).GET({ request })
|
|
71
|
+
// 非桌面 / 非 session 端点 / 非 2xx:原样透传
|
|
72
|
+
if (!isDesktopSessionEndpoint(request) || !response.ok) return response
|
|
73
|
+
|
|
74
|
+
// authjs session 响应是 JSON:有会话 → { user, expires };无会话 → null。
|
|
75
|
+
// Response body 只能读一次,故读文本后统一重建。
|
|
76
|
+
const text = await response.text()
|
|
77
|
+
let payload: unknown
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(text)
|
|
80
|
+
} catch {
|
|
81
|
+
return rebuildSessionResponse(response, text)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const authUser = (payload as { user?: { id?: string } | null } | null)?.user
|
|
85
|
+
// 桌面本地凭据登录(有 authjs cookie):authjs 结果优先,原样返回
|
|
86
|
+
if (authUser?.id) return rebuildSessionResponse(response, payload)
|
|
87
|
+
|
|
88
|
+
const session = await getCurrentSession(request)
|
|
89
|
+
if (!session?.user?.id) return rebuildSessionResponse(response, payload)
|
|
90
|
+
|
|
91
|
+
// guest 回落:懒 seed 本地影子用户(与 getSessionFn 同一幂等落库)。
|
|
92
|
+
// 只在 getSessionFn 里 seed 不够——root beforeLoad 的 RPC 失败/慢于端点时,
|
|
93
|
+
// 这里会下发一个 DB 里不存在的 guest 身份,随后任何写入都撞 FK(P2003)。
|
|
94
|
+
// 幂等 upsert,失败仅 warn:不阻断「我是谁」响应(首启体验优先)。
|
|
95
|
+
if (session.user.id === GUEST_USER_ID) {
|
|
96
|
+
try {
|
|
97
|
+
await ensureLocalShadowUser()
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn('[api/auth/session] 本地影子用户落库失败(不阻断 guest 身份响应):', err)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return rebuildSessionResponse(response, {
|
|
104
|
+
user: session.user,
|
|
105
|
+
expires: session.expires,
|
|
106
|
+
// 会话来源标记:user-menu 的云态登出编排需区分「云身份」与本地
|
|
107
|
+
// authjs 凭据登录(二者 user.isGuest 均为 false),旧后端无此字段
|
|
108
|
+
desktopSession: session.user.isGuest ? 'guest' : 'cloud',
|
|
109
|
+
})
|
|
110
|
+
},
|
|
26
111
|
POST: async ({ request }) => (await StartAuthJS(await getAuthConfig())).POST({ request }),
|
|
27
112
|
},
|
|
28
113
|
},
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// /api/auth/$ 的 GET 桌面回落(useSession 依赖的「我是谁」端点)。
|
|
2
|
+
// 根因:authjs 只认自己的 JWT cookie,桌面壳内(本地免登录 / 云端身份在
|
|
3
|
+
// remote-auth.json)恒返回 null → useSession() 恒 null → 各组件把本地用户当匿名。
|
|
4
|
+
// 修法:仅 GET /api/auth/session 在 desktop 且 authjs 无会话时回落
|
|
5
|
+
// getCurrentSession(authjs → remote-auth 云身份 → guest 影子身份)。
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const H = vi.hoisted(() => ({
|
|
9
|
+
getSession: vi.fn(),
|
|
10
|
+
readRemoteAuth: vi.fn(),
|
|
11
|
+
authJsGet: vi.fn(),
|
|
12
|
+
authJsPost: vi.fn(),
|
|
13
|
+
ensureLocalShadowUser: vi.fn(async () => 'local-guest'),
|
|
14
|
+
desktopServer: true,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock('start-authjs', () => ({
|
|
18
|
+
StartAuthJS: async () => ({ GET: H.authJsGet, POST: H.authJsPost }),
|
|
19
|
+
getSession: H.getSession,
|
|
20
|
+
}))
|
|
21
|
+
vi.mock('@/lib/auth/auth.config', () => ({
|
|
22
|
+
getAuthConfig: async () => ({}),
|
|
23
|
+
resetProfileCache: vi.fn(),
|
|
24
|
+
}))
|
|
25
|
+
vi.mock('@/server/remote-auth', () => ({
|
|
26
|
+
readRemoteAuth: H.readRemoteAuth,
|
|
27
|
+
}))
|
|
28
|
+
// seed 走真模块会拉起 @allbluecn/database 的模块级 prisma 单例(测试环境无 DB)
|
|
29
|
+
vi.mock('@/server/desktop-bootstrap', () => ({
|
|
30
|
+
ensureLocalShadowUser: H.ensureLocalShadowUser,
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
type AuthRoute = typeof import('../$')['Route']
|
|
34
|
+
|
|
35
|
+
async function importAuthRoute(desktop: boolean): Promise<AuthRoute> {
|
|
36
|
+
vi.resetModules()
|
|
37
|
+
H.desktopServer = desktop
|
|
38
|
+
vi.doMock('@/server/desktop-guest', async (importOriginal) => {
|
|
39
|
+
const actual = await importOriginal<typeof import('@/server/desktop-guest')>()
|
|
40
|
+
return { ...actual, isDesktopServer: () => H.desktopServer }
|
|
41
|
+
})
|
|
42
|
+
return (await import('../$')).Route
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 模拟 authjs 的 session 端点响应(无会话 → JSON null) */
|
|
46
|
+
function authJsResponse(body: unknown, init?: ResponseInit): Response {
|
|
47
|
+
return new Response(JSON.stringify(body), {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
...init,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function callGet(Route: AuthRoute, path = '/api/auth/session'): Promise<Response> {
|
|
55
|
+
const handlers = (Route.options as unknown as {
|
|
56
|
+
server: { handlers: Record<string, (ctx: { request: Request }) => Promise<Response>> }
|
|
57
|
+
}).server.handlers
|
|
58
|
+
return handlers.GET!({ request: new Request(`http://localhost${path}`, { method: 'GET' }) })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('/api/auth/session 桌面回落', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
// 真实桌面后端由 Electron 主进程注入该 env(fail-open 自检的正常路径):
|
|
64
|
+
// 测试里显式给出,避免每条用例都打安全告警
|
|
65
|
+
vi.stubEnv('DESKTOP_USER_DATA_DIR', '/tmp/allblue-desktop-test')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.unstubAllEnvs()
|
|
70
|
+
vi.clearAllMocks()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('desktop + 无 authjs cookie → 返回 guest 影子身份(user.id=local-guest)且懒 seed 影子用户', async () => {
|
|
74
|
+
H.getSession.mockResolvedValue(null)
|
|
75
|
+
H.readRemoteAuth.mockResolvedValue(null)
|
|
76
|
+
H.authJsGet.mockResolvedValue(authJsResponse(null))
|
|
77
|
+
|
|
78
|
+
const Route = await importAuthRoute(true)
|
|
79
|
+
const res = await callGet(Route)
|
|
80
|
+
const data = (await res.json()) as { user?: { id?: string; isGuest?: boolean }; desktopSession?: string }
|
|
81
|
+
|
|
82
|
+
expect(res.status).toBe(200)
|
|
83
|
+
expect(data.user?.id).toBe('local-guest')
|
|
84
|
+
expect(data.user?.isGuest).toBe(true)
|
|
85
|
+
expect(data.desktopSession).toBe('guest')
|
|
86
|
+
// I3:端点命中 guest 时补 seed(不能只靠 root beforeLoad 的 getSessionFn——
|
|
87
|
+
// 那一步 RPC 失败时这里会下发未落库身份,后续写入撞 FK P2003)
|
|
88
|
+
expect(H.ensureLocalShadowUser).toHaveBeenCalledTimes(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('desktop guest seed 抛错:仅 warn,不阻断「我是谁」响应', async () => {
|
|
92
|
+
H.getSession.mockResolvedValue(null)
|
|
93
|
+
H.readRemoteAuth.mockResolvedValue(null)
|
|
94
|
+
H.authJsGet.mockResolvedValue(authJsResponse(null))
|
|
95
|
+
H.ensureLocalShadowUser.mockRejectedValueOnce(new Error('db gone'))
|
|
96
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
97
|
+
|
|
98
|
+
const Route = await importAuthRoute(true)
|
|
99
|
+
const res = await callGet(Route)
|
|
100
|
+
const data = (await res.json()) as { user?: { id?: string } }
|
|
101
|
+
|
|
102
|
+
expect(res.status).toBe(200)
|
|
103
|
+
expect(data.user?.id).toBe('local-guest')
|
|
104
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('desktop + remoteAuth 云身份 → 返回云 userId(不是 guest),且不 seed guest 影子行', async () => {
|
|
108
|
+
H.getSession.mockResolvedValue(null)
|
|
109
|
+
H.readRemoteAuth.mockResolvedValue({
|
|
110
|
+
userId: 'cloud-user-abc',
|
|
111
|
+
planName: 'pro',
|
|
112
|
+
features: {},
|
|
113
|
+
sessionToken: 'cloud-session-token',
|
|
114
|
+
username: '云端用户',
|
|
115
|
+
})
|
|
116
|
+
H.authJsGet.mockResolvedValue(authJsResponse(null))
|
|
117
|
+
|
|
118
|
+
const Route = await importAuthRoute(true)
|
|
119
|
+
const data = (await (await callGet(Route)).json()) as {
|
|
120
|
+
user?: { id?: string; name?: string; isGuest?: boolean }
|
|
121
|
+
desktopSession?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
expect(data.user?.id).toBe('cloud-user-abc')
|
|
125
|
+
expect(data.user?.name).toBe('云端用户')
|
|
126
|
+
expect(data.user?.isGuest).toBe(false)
|
|
127
|
+
expect(data.desktopSession).toBe('cloud')
|
|
128
|
+
expect(H.ensureLocalShadowUser).not.toHaveBeenCalled()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('非 desktop:authjs 的 null 原样透传(不扩大暴露面)', async () => {
|
|
132
|
+
H.getSession.mockResolvedValue(null)
|
|
133
|
+
H.readRemoteAuth.mockResolvedValue(null)
|
|
134
|
+
H.authJsGet.mockResolvedValue(authJsResponse(null))
|
|
135
|
+
|
|
136
|
+
const Route = await importAuthRoute(false)
|
|
137
|
+
const data = await (await callGet(Route)).json()
|
|
138
|
+
|
|
139
|
+
expect(data).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('desktop + 有效 authjs 会话:authjs 结果优先(不追加 desktopSession 标记)', async () => {
|
|
143
|
+
const authJsSession = { user: { id: 'authjs-user-1', name: '本地账号' }, expires: '2026-12-31' }
|
|
144
|
+
H.getSession.mockResolvedValue(authJsSession)
|
|
145
|
+
H.authJsGet.mockResolvedValue(authJsResponse(authJsSession))
|
|
146
|
+
|
|
147
|
+
const Route = await importAuthRoute(true)
|
|
148
|
+
const data = (await (await callGet(Route)).json()) as Record<string, unknown>
|
|
149
|
+
|
|
150
|
+
expect((data.user as { id?: string }).id).toBe('authjs-user-1')
|
|
151
|
+
expect(data.desktopSession).toBeUndefined()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('desktop + 非 session 路径(csrf):原样透传,不做身份回落', async () => {
|
|
155
|
+
H.authJsGet.mockResolvedValue(
|
|
156
|
+
new Response(JSON.stringify({ csrfToken: 't' }), { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const Route = await importAuthRoute(true)
|
|
160
|
+
const data = (await (await callGet(Route, '/api/auth/csrf')).json()) as Record<string, unknown>
|
|
161
|
+
|
|
162
|
+
expect(data.csrfToken).toBe('t')
|
|
163
|
+
expect(H.getSession).not.toHaveBeenCalled()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('POST 原样交给 authjs(登录/登出流程零改动)', async () => {
|
|
167
|
+
H.authJsPost.mockResolvedValue(new Response('{}', { status: 200 }))
|
|
168
|
+
|
|
169
|
+
const Route = await importAuthRoute(true)
|
|
170
|
+
const handlers = (Route.options as unknown as {
|
|
171
|
+
server: { handlers: Record<string, (ctx: { request: Request }) => Promise<Response>> }
|
|
172
|
+
}).server.handlers
|
|
173
|
+
const res = await handlers.POST!({
|
|
174
|
+
request: new Request('http://localhost/api/auth/callback/credentials', { method: 'POST' }),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(H.authJsPost).toHaveBeenCalledTimes(1)
|
|
178
|
+
expect(res.status).toBe(200)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('透传 authjs 的 Set-Cookie(坏 cookie 清理头不丢)', async () => {
|
|
182
|
+
H.getSession.mockResolvedValue(null)
|
|
183
|
+
H.readRemoteAuth.mockResolvedValue(null)
|
|
184
|
+
H.authJsGet.mockResolvedValue(
|
|
185
|
+
authJsResponse(null, { headers: { 'content-type': 'application/json', 'set-cookie': 'auth.session-token=; Max-Age=0' } }),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
const Route = await importAuthRoute(true)
|
|
189
|
+
const res = await callGet(Route)
|
|
190
|
+
|
|
191
|
+
expect(res.headers.getSetCookie().some((c) => c.startsWith('auth.session-token='))).toBe(true)
|
|
192
|
+
expect(res.headers.get('cache-control')).toBe('no-store')
|
|
193
|
+
})
|
|
194
|
+
})
|