refactor(web): remove app initializer and move auth boot logic to route boundaries (#36818)

This commit is contained in:
yyh
2026-05-29 20:26:34 +08:00
committed by GitHub
parent ae538ced47
commit 9490d63c50
62 changed files with 1284 additions and 668 deletions
@@ -7,9 +7,13 @@ When('I open the publish panel', async function (this: DifyWorld) {
})
When('I publish the app', async function (this: DifyWorld) {
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
await this.getPage()
.getByRole('button', { name: /Publish Update/ })
.click()
})
Then('the app should be marked as published', async function (this: DifyWorld) {
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({
timeout: 30_000,
})
})
@@ -1,13 +1,21 @@
import type { DifyWorld } from '../../support/world'
import { Given, Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api'
import {
createTestApp,
enableAppSiteAndGetURL,
publishWorkflowApp,
syncRunnableWorkflowDraft,
} from '../../../support/api'
When('I enable the Web App share', async function (this: DifyWorld) {
const page = this.getPage()
const appName = this.lastCreatedAppName
if (!appName)
throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.')
if (!appName) {
throw new Error(
'No app name available. Run "a \\"workflow\\" app has been created via API" first.',
)
}
await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click()
await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 })
@@ -28,8 +36,11 @@ Given('a workflow app has been published and shared via API', async function (th
})
When('I open the shared app URL', async function (this: DifyWorld) {
if (!this.shareURL)
throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.')
if (!this.shareURL) {
throw new Error(
'No share URL available. Run "a workflow app has been published and shared via API" first.',
)
}
await this.getPage().goto(this.shareURL, { timeout: 20_000 })
})
@@ -12,7 +12,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
When('I run the workflow', async function (this: DifyWorld) {
const page = this.getPage()
const testRunButton = page.getByText('Test Run')
const testRunButton = page.getByRole('button', { name: /Test Run/ })
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
await testRunButton.click()
@@ -20,6 +20,6 @@ When('I run the workflow', async function (this: DifyWorld) {
Then('the workflow run should succeed', async function (this: DifyWorld) {
const page = this.getPage()
await page.getByText('DETAIL').click()
await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 })
await page.getByText('DETAIL', { exact: true }).click()
await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 })
})
+12
View File
@@ -510,6 +510,9 @@ catalogs:
scheduler:
specifier: 0.27.0
version: 0.27.0
server-only:
specifier: 0.0.1
version: 0.0.1
sharp:
specifier: 0.34.5
version: 0.34.5
@@ -1242,6 +1245,9 @@ importers:
scheduler:
specifier: 'catalog:'
version: 0.27.0
server-only:
specifier: 'catalog:'
version: 0.0.1
sharp:
specifier: 'catalog:'
version: 0.34.5
@@ -8413,6 +8419,9 @@ packages:
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
engines: {node: '>=10'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -16641,6 +16650,8 @@ snapshots:
seroval@1.5.1: {}
server-only@0.0.1: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
@@ -17758,6 +17769,7 @@ time:
remark-breaks@4.0.0: '2023-09-22T16:45:41.061Z'
remark-directive@4.0.0: '2025-02-27T15:15:20.630Z'
scheduler@0.27.0: '2025-10-01T21:39:15.208Z'
server-only@0.0.1: '2022-09-03T01:07:26.139Z'
sharp@0.34.5: '2025-11-06T14:19:40.989Z'
shiki@4.1.0: '2026-05-19T07:51:34.358Z'
socket.io-client@4.8.3: '2025-12-23T16:39:16.428Z'
+1
View File
@@ -213,6 +213,7 @@ catalog:
remark-breaks: 4.0.0
remark-directive: 4.0.0
scheduler: 0.27.0
server-only: 0.0.1
sharp: 0.34.5
shiki: 4.1.0
socket.io-client: 4.8.3
+3
View File
@@ -4,6 +4,9 @@ NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
NEXT_PUBLIC_EDITION=SELF_HOSTED
# The base path for the application
NEXT_PUBLIC_BASE_PATH=
# Server-only console API origin for server-side requests.
# Usually matches CONSOLE_API_URL from Docker deployment; local dev can rely on NEXT_PUBLIC_API_PREFIX fallback.
CONSOLE_API_URL=http://localhost:5001
# The base URL of console application, refers to the Console base URL of WEB service if console domain is
# different from api or web app domain.
# example: https://cloud.dify.ai/console/api
@@ -141,7 +141,6 @@ describe('Header Account Dropdown Flow', () => {
})
it('logs out, resets cached user markers, and redirects to signin', async () => {
localStorage.setItem('setup_status', 'done')
localStorage.setItem('education-reverify-prev-expire-at', '1')
localStorage.setItem('education-reverify-has-noticed', '1')
localStorage.setItem('education-expired-has-noticed', '1')
@@ -157,7 +156,6 @@ describe('Header Account Dropdown Flow', () => {
expect(mockPush).toHaveBeenCalledWith('/signin')
})
expect(localStorage.getItem('setup_status')).toBeNull()
expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull()
expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull()
expect(localStorage.getItem('education-expired-has-noticed')).toBeNull()
@@ -0,0 +1,124 @@
import type { ReactElement } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
queryClient: undefined as QueryClient | undefined,
profileQueryFn: vi.fn(),
systemFeaturesQueryFn: vi.fn(),
redirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`)
}),
headers: vi.fn(),
resolveServerConsoleApiUrl: vi.fn(),
}))
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => mocks.queryClient,
}))
vi.mock('@/next/headers', () => ({
headers: () => mocks.headers(),
}))
vi.mock('@/next/navigation', () => ({
redirect: (url: string) => mocks.redirect(url),
}))
vi.mock('@/features/account-profile/server', () => ({
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
serverUserProfileQueryOptions: () => ({
queryKey: ['common', 'user-profile'],
queryFn: mocks.profileQueryFn,
retry: false,
}),
}))
vi.mock('@/service/system-features', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['console', 'system-features'],
queryFn: mocks.systemFeaturesQueryFn,
retry: false,
}),
}))
describe('CommonLayoutHydrationBoundary', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.headers.mockResolvedValue(new Headers({
'x-dify-pathname': '/apps',
'x-dify-search': '?tag=workflow',
}))
mocks.resolveServerConsoleApiUrl.mockReturnValue('https://console.example.com/console/api/account/profile')
mocks.profileQueryFn.mockResolvedValue({
profile: {
id: 'account-id',
name: 'Dify User',
email: 'user@example.com',
avatar: '',
avatar_url: null,
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'DEVELOPMENT',
},
})
mocks.systemFeaturesQueryFn.mockResolvedValue({ branding: { enabled: false } })
})
it('should hydrate common layout queries and render children', async () => {
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({
children: <div>Common shell</div>,
})
render(
<QueryClientProvider client={new QueryClient()}>
{element as ReactElement}
</QueryClientProvider>,
)
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
})
it('should redirect unauthorized users to the refresh route with the current path', async () => {
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }))
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2Fapps%3Ftag%3Dworkflow')
})
it('should redirect setup errors to install', async () => {
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 }))
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
expect(mocks.redirect).toHaveBeenCalledWith('/install')
})
it('should render children without server prefetch when the server API URL is not resolvable', async () => {
mocks.resolveServerConsoleApiUrl.mockReturnValue(null)
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({
children: <div>Common shell</div>,
})
render(
<QueryClientProvider client={new QueryClient()}>
{element as ReactElement}
</QueryClientProvider>,
)
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).not.toHaveBeenCalled()
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
})
})
@@ -25,6 +25,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { getLocalStorageItem, useLocalStorageBoolean } from '@/utils/local-storage'
type IAppDetailLayoutProps = {
children: React.ReactNode
@@ -54,13 +55,14 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
const hideHeader = eventHideHeader ?? storedHideHeader
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v?.type === 'workflow-canvas-maximize')
setHideHeader(v.payload)
setEventHideHeader(v.payload)
})
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
@@ -125,7 +127,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const localeMode = getLocalStorageItem('app-detail-collapse-or-expand', 'expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])
@@ -1,8 +1,8 @@
'use client'
import { useEffect } from 'react'
import { FullScreenLoading } from '@/app/components/full-screen-loading'
import EducationApplyPage from '@/app/education-apply/education-apply-page'
import RootLoading from '@/app/loading'
import { useProviderContext } from '@/context/provider-context'
import {
useRouter,
@@ -28,7 +28,7 @@ export default function EducationApply() {
}, [enableEducationPlan, isFetchedPlanInfo, router, token])
if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo)
return <RootLoading />
return <FullScreenLoading />
return <EducationApplyPage />
}
+3 -3
View File
@@ -2,8 +2,8 @@
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import RootLoading from '@/app/loading'
import { isLegacyBase401 } from '@/service/use-common'
import { FullScreenLoading } from '@/app/components/full-screen-loading'
import { isLegacyBase401 } from '@/features/account-profile/client'
type Props = {
error: Error & { digest?: string }
@@ -18,7 +18,7 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
// Showing the "Try again" button here would just flash for a few frames before
// the page navigates away, and clicking it would 401 again anyway.
if (isLegacyBase401(error))
return <RootLoading />
return <FullScreenLoading />
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClientServer } from '@/context/query-client-server'
import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server'
import { headers } from '@/next/headers'
import { redirect } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { basePath } from '@/utils/var'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
const CURRENT_SEARCH_HEADER = 'x-dify-search'
const ACCOUNT_PROFILE_PATH = '/account/profile'
const AUTH_REFRESH_PATH = '/auth/refresh'
type ConsoleErrorPayload = {
code?: string
}
const isConsoleErrorPayload = (value: unknown): value is ConsoleErrorPayload =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const parseConsoleErrorPayload = async (error: Response): Promise<ConsoleErrorPayload | null> => {
try {
const payload: unknown = await error.clone().json()
return isConsoleErrorPayload(payload) ? payload : null
}
catch {
return null
}
}
const getCurrentPath = async () => {
const requestHeaders = await headers()
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || `${basePath}/apps`
const search = requestHeaders.get(CURRENT_SEARCH_HEADER) || ''
return `${pathname}${search}`
}
const redirectToAuthRefresh = async () => {
const currentPath = await getCurrentPath()
redirect(`${basePath}${AUTH_REFRESH_PATH}?redirect_url=${encodeURIComponent(currentPath)}`)
}
const handleProfileError = async (error: unknown) => {
if (!(error instanceof Response))
throw error
const errorData = await parseConsoleErrorPayload(error)
if (errorData?.code === 'not_setup')
redirect(`${basePath}/install`)
if (errorData?.code === 'not_init_validated')
redirect(`${basePath}/init`)
if (error.status === 401)
await redirectToAuthRefresh()
throw error
}
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
const queryClient = getQueryClientServer()
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (!accountProfileUrl) {
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}
try {
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(systemFeaturesQueryOptions()),
])
}
catch (error) {
await handleProfileError(error)
}
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}
+9 -5
View File
@@ -1,27 +1,31 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import PartnerStack from '../components/billing/partner-stack'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
import RoleRouteGuard from './role-route-guard'
const Layout = ({ children }: { children: ReactNode }) => {
const Layout = async ({ children }: { children: ReactNode }) => {
return (
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<AppInitializer>
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
@@ -40,8 +44,8 @@ const Layout = ({ children }: { children: ReactNode }) => {
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<Zendesk />
</AppInitializer>
</CommonLayoutHydrationBoundary>
<Zendesk />
</>
)
}
-9
View File
@@ -1,9 +0,0 @@
import Loading from '@/app/components/base/loading'
export default function CommonLayoutLoading() {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
<Loading />
</div>
)
}
@@ -216,7 +216,6 @@ const EmailChangeModal = ({ onClose, email }: Props) => {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
@@ -16,10 +16,10 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { consoleQuery } from '@/service/client'
import { updateUserProfile } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
@@ -49,7 +49,7 @@ export default function AccountPage() {
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const userProfile = userProfileResp.profile
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
const { isEducationAccount } = useProviderContext()
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')
+2 -2
View File
@@ -12,8 +12,9 @@ import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useRouter } from '@/next/navigation'
import { useLogout, userProfileQueryOptions } from '@/service/use-common'
import { useLogout } from '@/service/use-common'
export default function AppSelector() {
const router = useRouter()
@@ -31,7 +32,6 @@ export default function AppSelector() {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
resetUser()
// Tokens are now stored in cookies and cleared by backend
+8 -4
View File
@@ -1,21 +1,25 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import Header from './header'
const Layout = ({ children }: { children: ReactNode }) => {
const Layout = async ({ children }: { children: ReactNode }) => {
return (
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<AppInitializer>
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
@@ -30,7 +34,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</AppInitializer>
</CommonLayoutHydrationBoundary>
</>
)
}
+1 -1
View File
@@ -5,9 +5,9 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import Loading from '@/app/components/base/loading'
import Header from '@/app/signin/_header'
import { AppContextProvider } from '@/context/app-context-provider'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
export default function SignInLayout({ children }: any) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
+2 -3
View File
@@ -16,9 +16,9 @@ import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { setOAuthPendingRedirect } from '@/app/signin/utils/post-login-redirect'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import { useRouter, useSearchParams } from '@/next/navigation'
import { isLegacyBase401, useLogout, userProfileQueryOptions } from '@/service/use-common'
import { useLogout } from '@/service/use-common'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
function buildReturnUrl(pathname: string, search: string) {
@@ -80,7 +80,6 @@ export default function OAuthAuthorize() {
const onLoginSwitchClick = async () => {
try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`)
setOAuthPendingRedirect(returnUrl)
if (isLoggedIn)
await logout()
router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`)
@@ -0,0 +1,104 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: 'http://localhost:5001/console/api',
}))
vi.mock('@/config/server', () => ({
SERVER_CONSOLE_API_PREFIX: undefined,
}))
vi.mock('@/utils/var', () => ({
basePath: '',
}))
const getSetCookieHeaders = (headers: Headers) => {
const getSetCookie = Reflect.get(headers, 'getSetCookie')
if (typeof getSetCookie === 'function') {
const values: unknown = getSetCookie.call(headers)
return Array.isArray(values) ? values : []
}
const setCookie = headers.get('set-cookie')
return setCookie ? [setCookie] : []
}
const createRequest = (url: string, cookie?: string) => ({
url,
headers: new Headers(cookie ? { cookie } : undefined),
}) as Request
describe('auth refresh route', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
})
it('should refresh cookies and redirect back to the requested path', async () => {
const headers = new Headers()
Object.defineProperty(headers, 'getSetCookie', {
value: () => [
'access_token=new-access; Path=/; HttpOnly',
'refresh_token=new-refresh; Path=/; HttpOnly',
],
})
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers,
} as Response)
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps%3Fcategory%3Dworkflow',
'refresh_token=old-refresh',
))
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:5001/console/api/refresh-token',
expect.objectContaining({
method: 'POST',
cache: 'no-store',
headers: expect.any(Headers),
}),
)
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(fetchHeaders.get('cookie')).toBe('refresh_token=old-refresh')
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/apps?category=workflow')
expect(getSetCookieHeaders(response.headers)).toEqual([
'access_token=new-access; Path=/; HttpOnly',
'refresh_token=new-refresh; Path=/; HttpOnly',
])
})
it('should redirect to signin when refresh token is rejected', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps',
'refresh_token=expired',
))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
})
it('should ignore cross-origin redirect targets', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }))
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
'refresh_token=expired',
))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
})
})
+113
View File
@@ -0,0 +1,113 @@
import { API_PREFIX } from '@/config'
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
import { basePath } from '@/utils/var'
const REFRESH_TOKEN_PATH = '/refresh-token'
const AUTH_REFRESH_PATH = `${basePath}/auth/refresh`
const DEFAULT_REDIRECT_PATH = `${basePath}/apps`
const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/`
const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value
const resolveAbsoluteUrlPrefix = (value: string) => {
try {
return new URL(value).toString()
}
catch {
return null
}
}
const resolveServerConsoleApiUrl = (pathname: string, requestUrl: URL) => {
const requestPath = withoutLeadingSlash(pathname)
const apiPrefix = SERVER_CONSOLE_API_PREFIX
|| resolveAbsoluteUrlPrefix(API_PREFIX)
|| new URL(API_PREFIX, requestUrl.origin).toString()
if (!apiPrefix)
return null
return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
}
const resolveSafeRedirectPath = (request: Request) => {
const requestUrl = new URL(request.url)
const redirectUrl = requestUrl.searchParams.get('redirect_url')
if (!redirectUrl)
return DEFAULT_REDIRECT_PATH
try {
const target = new URL(redirectUrl, requestUrl.origin)
if (target.origin !== requestUrl.origin)
return DEFAULT_REDIRECT_PATH
if (target.pathname === AUTH_REFRESH_PATH)
return DEFAULT_REDIRECT_PATH
return `${target.pathname}${target.search}`
}
catch {
return DEFAULT_REDIRECT_PATH
}
}
const getSetCookieHeaders = (headers: Headers) => {
const getSetCookie = Reflect.get(headers, 'getSetCookie')
if (typeof getSetCookie === 'function') {
const values: unknown = getSetCookie.call(headers)
return Array.isArray(values)
? values.filter((value): value is string => typeof value === 'string')
: []
}
const setCookie = headers.get('set-cookie')
return setCookie ? [setCookie] : []
}
const createRedirectResponse = (request: Request, pathname: string, setCookies: string[] = []) => {
const headers = new Headers({
'Cache-Control': 'no-store',
'Location': new URL(pathname, request.url).toString(),
})
for (const cookie of setCookies)
headers.append('Set-Cookie', cookie)
return new Response(null, {
status: 303,
headers,
})
}
const createSigninRedirectResponse = (request: Request, redirectPath: string) =>
createRedirectResponse(request, `${basePath}/signin?redirect_url=${encodeURIComponent(redirectPath)}`)
export async function GET(request: Request) {
const requestUrl = new URL(request.url)
const redirectPath = resolveSafeRedirectPath(request)
const refreshUrl = resolveServerConsoleApiUrl(REFRESH_TOKEN_PATH, requestUrl)
const cookie = request.headers.get('cookie')
if (!refreshUrl || !cookie)
return createSigninRedirectResponse(request, redirectPath)
try {
const response = await fetch(refreshUrl, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
cookie,
}),
cache: 'no-store',
})
if (!response.ok)
return createSigninRedirectResponse(request, redirectPath)
return createRedirectResponse(request, redirectPath, getSetCookieHeaders(response.headers))
}
catch {
return createSigninRedirectResponse(request, redirectPath)
}
}
@@ -1,197 +0,0 @@
import { screen, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { AppInitializer } from '../app-initializer'
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
mockSendGAEvent: vi.fn(),
mockTrackEvent: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/utils/setup-status', () => ({
fetchSetupStatusWithCache: vi.fn(),
}))
vi.mock('@/app/signin/utils/post-login-redirect', () => ({
resolvePostLoginRedirect: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
}))
vi.mock('../base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
const mockUsePathname = vi.mocked(usePathname)
const mockUseRouter = vi.mocked(useRouter)
const mockUseSearchParams = vi.mocked(useSearchParams)
const mockFetchSetupStatusWithCache = vi.mocked(fetchSetupStatusWithCache)
const mockResolvePostLoginRedirect = vi.mocked(resolvePostLoginRedirect)
const mockReplace = vi.fn()
describe('AppInitializer', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
window.localStorage.clear()
window.sessionStorage.clear()
Cookies.remove('utm_info')
vi.spyOn(console, 'error').mockImplementation(() => {})
mockUsePathname.mockReturnValue('/apps')
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' })
mockResolvePostLoginRedirect.mockReturnValue(null)
})
it('renders children after setup checks finish', async () => {
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1)
expect(mockReplace).not.toHaveBeenCalledWith('/signin')
})
it('redirects to install when setup status loading fails', async () => {
mockFetchSetupStatusWithCache.mockRejectedValue(new Error('unauthorized'))
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/install'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
it('does not persist create app attribution from the url anymore', async () => {
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
})
it('tracks oauth registration with utm info and clears the cookie', async () => {
Cookies.set('utm_info', JSON.stringify({
utm_source: 'linkedin',
slug: 'agent-launch',
}))
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
{ searchParams: 'oauth_new_user=true' },
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(mockReplace).toHaveBeenCalledWith('/apps')
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('falls back to the base registration event when the oauth utm cookie is invalid', async () => {
Cookies.set('utm_info', '{invalid-json')
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
{ searchParams: 'oauth_new_user=true' },
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(console.error).toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('stores the education verification flag in localStorage', async () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
)
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
})
it('redirects to the resolved post-login url when one exists', async () => {
const mockLocationReplace = vi.fn()
vi.stubGlobal('location', { ...window.location, replace: mockLocationReplace })
mockResolvePostLoginRedirect.mockReturnValue('/explore')
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockLocationReplace).toHaveBeenCalledWith('/explore'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
it('redirects to signin when redirect resolution throws', async () => {
mockResolvePostLoginRedirect.mockImplementation(() => {
throw new Error('redirect resolution failed')
})
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,40 @@
import { render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { useSearchParams } from '@/next/navigation'
import { EducationVerifyActionRecorder } from '../education-verify-action-recorder'
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
describe('EducationVerifyActionRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
})
it('should store the education verification flag when the callback action is present', async () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
)
render(<EducationVerifyActionRecorder />)
await waitFor(() => {
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
})
})
it('should leave localStorage unchanged for unrelated routes', () => {
render(<EducationVerifyActionRecorder />)
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBeNull()
})
})
@@ -0,0 +1,106 @@
import { render, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSearchParams } from '@/next/navigation'
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
mockSendGAEvent: vi.fn(),
mockTrackEvent: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('../base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
const setSearchParams = (searchParams = '') => {
mockUseSearchParams.mockReturnValue(new URLSearchParams(searchParams) as unknown as ReturnType<typeof useSearchParams>)
window.history.replaceState(null, '', `/signin${searchParams ? `?${searchParams}` : ''}`)
}
describe('OAuthRegistrationAnalytics', () => {
beforeEach(() => {
vi.clearAllMocks()
Cookies.remove('utm_info')
vi.spyOn(console, 'error').mockImplementation(() => {})
setSearchParams()
})
it('should track oauth registration with utm info and clear the query flag', async () => {
Cookies.set('utm_info', JSON.stringify({
utm_source: 'linkedin',
slug: 'agent-launch',
}))
setSearchParams('oauth_new_user=true&source=signin')
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(Cookies.get('utm_info')).toBeUndefined()
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin')
})
})
it('should fall back to the base registration event when the utm cookie is invalid', async () => {
Cookies.set('utm_info', '{invalid-json')
setSearchParams('oauth_new_user=true')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(console.error).toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('should do nothing without the oauth registration query flag', () => {
render(<OAuthRegistrationAnalytics />)
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
it('should clear a false oauth registration query flag without tracking', async () => {
setSearchParams('oauth_new_user=false')
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
})
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
})
-103
View File
@@ -1,103 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import RootLoading from '@/app/loading'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
type AppInitializerProps = {
children: ReactNode
}
export const AppInitializer = ({
children,
}: AppInitializerProps) => {
const router = useRouter()
const searchParams = useSearchParams()
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser] = useQueryState(
'oauth_new_user',
parseAsBoolean.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
try {
const setUpStatus = await fetchSetupStatusWithCache()
return setUpStatus.step === 'finished'
}
catch (error) {
console.error(error)
return false
}
}, [])
useEffect(() => {
(async () => {
const action = searchParams.get('action')
if (oauthNewUser) {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
utmInfo = JSON.parse(utmInfoStr)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
// Track registration event with UTM params
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
Cookies.remove('utm_info')
}
if (oauthNewUser !== null)
router.replace(pathname)
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
try {
const isFinished = await isSetupFinished()
if (!isFinished) {
router.replace('/install')
return
}
const redirectUrl = resolvePostLoginRedirect(searchParams)
if (redirectUrl) {
location.replace(redirectUrl)
return
}
setInit(true)
}
catch {
router.replace('/signin')
}
})()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
return init ? children : <RootLoading />
}
@@ -0,0 +1,19 @@
'use client'
import { useEffect } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { useSearchParams } from '@/next/navigation'
export function EducationVerifyActionRecorder() {
const searchParams = useSearchParams()
useEffect(() => {
if (searchParams.get('action') === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
}, [searchParams])
return null
}
@@ -1,6 +1,6 @@
import Loading from '@/app/components/base/loading'
import Loading from './base/loading'
export default function RootLoading() {
export function FullScreenLoading() {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
<Loading />
@@ -277,7 +277,6 @@ describe('AccountDropdown', () => {
// Assert
await waitFor(() => {
expect(mockLogout).toHaveBeenCalled()
expect(localStorage.removeItem).toHaveBeenCalledWith('setup_status')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})
@@ -121,7 +121,6 @@ export default function AppSelector() {
const handleLogout = async () => {
await logout()
resetUser()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
// To avoid use other account's education notice info
+5 -3
View File
@@ -4,6 +4,7 @@ import * as React from 'react'
import { useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { usePathname } from '@/next/navigation'
import { useLocalStorageBoolean } from '@/utils/local-storage'
import s from './index.module.css'
type HeaderWrapperProps = {
@@ -18,13 +19,14 @@ const HeaderWrapper = ({
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
const hideHeader = eventHideHeader ?? storedHideHeader
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v?.type === 'workflow-canvas-maximize')
setHideHeader(v.payload)
setEventHideHeader(v.payload)
})
return (
@@ -3,19 +3,22 @@ import { useTranslation } from 'react-i18next'
import { X } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { NOTICE_I18N } from '@/i18n-config/language'
import { setLocalStorageItem, useLocalStorageItem } from '@/utils/local-storage'
const MaintenanceNotice = () => {
const { t } = useTranslation()
const locale = useLanguage()
const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1')
const hiddenNotice = useLocalStorageItem('hide-maintenance-notice') === '1'
const [closedInSession, setClosedInSession] = useState(false)
const showNotice = !hiddenNotice && !closedInSession
const handleJumpNotice = () => {
window.open(NOTICE_I18N.href, '_blank')
}
const handleCloseNotice = () => {
localStorage.setItem('hide-maintenance-notice', '1')
setShowNotice(false)
setLocalStorageItem('hide-maintenance-notice', '1')
setClosedInSession(true)
}
const titleByLocale: { [key: string]: string } = NOTICE_I18N.title
@@ -0,0 +1,66 @@
'use client'
import Cookies from 'js-cookie'
import { useEffect, useRef } from 'react'
import { useSearchParams } from '@/next/navigation'
import { sendGAEvent } from '@/utils/gtag'
import { trackEvent } from './base/amplitude'
const OAUTH_NEW_USER_PARAM = 'oauth_new_user'
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const removeOAuthNewUserParam = () => {
const url = new URL(window.location.href)
url.searchParams.delete(OAUTH_NEW_USER_PARAM)
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
}
export function OAuthRegistrationAnalytics() {
const searchParams = useSearchParams()
const oauthNewUserParam = searchParams.get(OAUTH_NEW_USER_PARAM)
const handledParamRef = useRef<string | null>(null)
useEffect(() => {
if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam)
return
handledParamRef.current = oauthNewUserParam
const oauthNewUser = oauthNewUserParam === 'true'
if (!oauthNewUser) {
removeOAuthNewUserParam()
return
}
let utmInfo: Record<string, unknown> | null = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
const parsed: unknown = JSON.parse(utmInfoStr)
if (isRecord(parsed))
utmInfo = parsed
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success'
trackEvent(eventName, {
method: 'oauth',
...utmInfo,
})
sendGAEvent(eventName, {
method: 'oauth',
...utmInfo,
})
Cookies.remove('utm_info')
removeOAuthNewUserParam()
}, [oauthNewUserParam])
return null
}
@@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import { getLocalStorageBoolean, getLocalStorageNumber } from '@/utils/local-storage'
export type LayoutSliceShape = {
workflowCanvasWidth?: number
@@ -34,10 +35,10 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
rightPanelWidth: undefined,
setRightPanelWidth: width => set(state =>
state.rightPanelWidth === width ? state : ({ rightPanelWidth: width })),
nodePanelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 400,
nodePanelWidth: getLocalStorageNumber('workflow-node-panel-width', 400),
setNodePanelWidth: width => set(state =>
state.nodePanelWidth === width ? state : ({ nodePanelWidth: width })),
previewPanelWidth: localStorage.getItem('debug-and-preview-panel-width') ? Number.parseFloat(localStorage.getItem('debug-and-preview-panel-width')!) : 400,
previewPanelWidth: getLocalStorageNumber('debug-and-preview-panel-width', 400),
setPreviewPanelWidth: width => set(state =>
state.previewPanelWidth === width ? state : ({ previewPanelWidth: width })),
otherPanelWidth: 400,
@@ -49,10 +50,10 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
bottomPanelHeight: 324,
setBottomPanelHeight: height => set(state =>
state.bottomPanelHeight === height ? state : ({ bottomPanelHeight: height })),
variableInspectPanelHeight: localStorage.getItem('workflow-variable-inpsect-panel-height') ? Number.parseFloat(localStorage.getItem('workflow-variable-inpsect-panel-height')!) : 320,
variableInspectPanelHeight: getLocalStorageNumber('workflow-variable-inpsect-panel-height', 320),
setVariableInspectPanelHeight: height => set(state =>
state.variableInspectPanelHeight === height ? state : ({ variableInspectPanelHeight: height })),
maximizeCanvas: localStorage.getItem('workflow-canvas-maximize') === 'true',
maximizeCanvas: getLocalStorageBoolean('workflow-canvas-maximize'),
setMaximizeCanvas: maximize => set(state =>
state.maximizeCanvas === maximize ? state : ({ maximizeCanvas: maximize })),
})
@@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import { getLocalStorageNumber } from '@/utils/local-storage'
export type WorkflowContextMenuTarget
= | { type: 'panel' }
@@ -33,7 +34,7 @@ export type PanelSliceShape = {
}
export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
panelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 420,
panelWidth: getLocalStorageNumber('workflow-node-panel-width', 420),
showFeaturesPanel: false,
setShowFeaturesPanel: showFeaturesPanel => set(() => ({ showFeaturesPanel })),
showWorkflowVersionHistoryPanel: false,
@@ -6,6 +6,7 @@ import type {
WorkflowRunningData,
} from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { getLocalStorageItem, setLocalStorageItem } from '@/utils/local-storage'
type PreviewRunningData = WorkflowRunningData & {
resultTabActive?: boolean
@@ -21,6 +22,14 @@ type MousePosition = {
elementY: number
}
const getStoredControlMode = () => {
const storedControlMode = getLocalStorageItem('workflow-operation-mode')
if (storedControlMode === 'pointer' || storedControlMode === 'hand' || storedControlMode === 'comment')
return storedControlMode
return 'pointer'
}
export type WorkflowSliceShape = {
workflowRunningData?: PreviewRunningData
setWorkflowRunningData: (workflowData: PreviewRunningData) => void
@@ -92,16 +101,10 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
setSelection: selection => set(() => ({ selection })),
bundleNodeSize: null,
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
controlMode: (() => {
const storedControlMode = localStorage.getItem('workflow-operation-mode')
if (storedControlMode === 'pointer' || storedControlMode === 'hand' || storedControlMode === 'comment')
return storedControlMode
return 'pointer'
})(),
controlMode: getStoredControlMode(),
setControlMode: (controlMode) => {
set(() => ({ controlMode }))
localStorage.setItem('workflow-operation-mode', controlMode)
setLocalStorageItem('workflow-operation-mode', controlMode)
},
pendingComment: null,
setPendingComment: pendingComment => set(() => ({ pendingComment })),
@@ -39,8 +39,11 @@ vi.mock('@/service/system-features', () => ({
systemFeaturesQueryOptions: () => ({ queryKey: ['sys'], queryFn: async () => ({}) }),
}))
vi.mock('@/service/use-common', () => ({
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({ queryKey: ['profile'], queryFn: async () => null }),
}))
vi.mock('@/service/use-common', () => ({
commonQueryKeys: { currentWorkspace: ['currentWorkspace'] },
}))
+2 -1
View File
@@ -5,11 +5,12 @@ import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import Divider from '@/app/components/base/divider'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { post } from '@/service/base'
import { deviceLookup } from '@/service/device-flow'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
import { commonQueryKeys } from '@/service/use-common'
import AuthorizeAccount from './components/authorize-account'
import AuthorizeSSO from './components/authorize-sso'
import Chooser from './components/chooser'
-1
View File
@@ -15,7 +15,6 @@ const UserInfo = () => {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
+33
View File
@@ -0,0 +1,33 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { FullScreenLoading } from '@/app/components/full-screen-loading'
import { isLegacyBase401 } from '@/features/account-profile/client'
type Props = {
error: Error & { digest?: string }
reset?: () => void
unstable_retry?: () => void
}
export default function AppError({ error, reset, unstable_retry }: Props) {
const { t } = useTranslation('common')
const retry = reset ?? unstable_retry
if (isLegacyBase401(error))
return <FullScreenLoading />
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
<div className="system-sm-regular text-text-tertiary">
{t('errorBoundary.message')}
</div>
{retry && (
<Button size="small" variant="secondary" onClick={() => retry()}>
{t('errorBoundary.tryAgain')}
</Button>
)}
</div>
)
}
-1
View File
@@ -154,7 +154,6 @@ describe('InstallForm', () => {
render(<InstallForm />)
await waitFor(() => {
expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})
-1
View File
@@ -87,7 +87,6 @@ const InstallForm = () => {
useEffect(() => {
fetchSetupStatus().then((res: SetupStatusResponse) => {
if (res.step === 'finished') {
localStorage.setItem('setup_status', 'finished')
router.push('/signin')
}
else {
+1 -1
View File
@@ -6,11 +6,11 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CE_EDITION } from '@/config'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import Link from '@/next/link'
import { useRouter, useSearchParams } from '@/next/navigation'
import { invitationCheck } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
import { LicenseStatus } from '@/types/feature'
import Loading from '../components/base/loading'
import MailAndCodeAuth from './components/mail-and-code-auth'
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolvePostLoginRedirect, setPostLoginRedirect } from '../post-login-redirect'
describe('post-login redirect utilities', () => {
beforeEach(() => {
vi.useRealTimers()
window.localStorage.clear()
window.sessionStorage.clear()
})
it('should use the redirect_url query param first', () => {
const searchParams = new URLSearchParams({
redirect_url: encodeURIComponent('/account/oauth/authorize?client_id=app&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback'),
})
expect(resolvePostLoginRedirect(searchParams as unknown as Parameters<typeof resolvePostLoginRedirect>[0])).toBe('/account/oauth/authorize?client_id=app&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback')
})
it('should recover a valid device redirect from sessionStorage once', () => {
setPostLoginRedirect('/device?user_code=ABCD&sso_verified=true')
expect(resolvePostLoginRedirect()).toBe('/device?user_code=ABCD&sso_verified=true')
expect(resolvePostLoginRedirect()).toBeNull()
})
it('should ignore invalid stored redirects', () => {
setPostLoginRedirect('https://example.com/device?user_code=ABCD')
expect(resolvePostLoginRedirect()).toBeNull()
})
})
+1 -46
View File
@@ -1,6 +1,5 @@
import type { ReadonlyURLSearchParams } from '@/next/navigation'
const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending_redirect'
const REDIRECT_URL_KEY = 'redirect_url'
const DEVICE_REDIRECT_KEY = 'dify_post_login_redirect'
const DEVICE_TTL_MS = 15 * 60 * 1000
@@ -10,13 +9,6 @@ const ALLOWED: Record<string, ReadonlySet<string>> = {
'/account/oauth/authorize': new Set(['client_id', 'scope', 'state', 'redirect_uri']),
}
type OAuthPendingRedirect = {
value?: string
expiry?: number
}
const getCurrentUnixTimestamp = () => Math.floor(Date.now() / 1000)
function validate(target: string): string | null {
if (typeof window === 'undefined')
return null
@@ -87,51 +79,14 @@ function getDeviceRedirect(): string | null {
}
}
function removeOAuthPendingRedirect() {
try {
localStorage.removeItem(OAUTH_AUTHORIZE_PENDING_KEY)
}
catch {}
}
function getOAuthPendingRedirect(): string | null {
try {
const raw = localStorage.getItem(OAUTH_AUTHORIZE_PENDING_KEY)
if (!raw)
return null
removeOAuthPendingRedirect()
const item: OAuthPendingRedirect = JSON.parse(raw)
if (!item.value || typeof item.expiry !== 'number')
return null
return getCurrentUnixTimestamp() > item.expiry ? null : item.value
}
catch {
removeOAuthPendingRedirect()
return null
}
}
export function setOAuthPendingRedirect(url: string, ttlSeconds: number = 300) {
try {
const item: OAuthPendingRedirect = {
value: url,
expiry: getCurrentUnixTimestamp() + ttlSeconds,
}
localStorage.setItem(OAUTH_AUTHORIZE_PENDING_KEY, JSON.stringify(item))
}
catch {}
}
export const resolvePostLoginRedirect = (searchParams?: ReadonlyURLSearchParams) => {
if (searchParams) {
const redirectUrl = searchParams.get(REDIRECT_URL_KEY)
if (redirectUrl) {
try {
removeOAuthPendingRedirect()
return decodeURIComponent(redirectUrl)
}
catch {
removeOAuthPendingRedirect()
return redirectUrl
}
}
@@ -139,5 +94,5 @@ export const resolvePostLoginRedirect = (searchParams?: ReadonlyURLSearchParams)
const device = getDeviceRedirect()
if (device)
return device
return getOAuthPendingRedirect()
return null
}
+10
View File
@@ -0,0 +1,10 @@
import { env } from '@/env'
import 'server-only'
const withoutTrailingSlash = (value: string) => value.endsWith('/') ? value.slice(0, -1) : value
// Server-side requests need the origin; browser requests should keep using NEXT_PUBLIC_API_PREFIX.
export const SERVER_CONSOLE_API_PREFIX = env.CONSOLE_API_URL
? `${withoutTrailingSlash(env.CONSOLE_API_URL)}/console/api`
: undefined
+2 -7
View File
@@ -16,11 +16,11 @@ import {
useSelector,
} from '@/context/app-context'
import { env } from '@/env'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import {
useCurrentWorkspace,
useLangGeniusVersion,
userProfileQueryOptions,
} from '@/service/use-common'
type AppContextProviderProps = {
@@ -29,11 +29,6 @@ type AppContextProviderProps = {
export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) => {
const queryClient = useQueryClient()
// Boot point for the (commonLayout) tree:
// - useSuspenseQuery for systemFeatures triggers app/loading.tsx until cache is warm.
// - useSuspenseQuery for userProfile triggers (commonLayout)/loading.tsx until cache is warm.
// After this provider mounts, downstream components reading the same queryKeys hit cache
// and never suspend again, so their useSuspenseQuery calls return data synchronously.
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace, isFetching: isValidatingCurrentWorkspace } = useCurrentWorkspace()
@@ -65,7 +60,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
const isCurrentWorkspaceDatasetOperator = useMemo(() => currentWorkspace.role === 'dataset_operator', [currentWorkspace.role])
const mutateUserProfile = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['common', 'user-profile'] })
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
}, [queryClient])
const mutateCurrentWorkspace = useCallback(() => {
+23
View File
@@ -1,6 +1,29 @@
import { type } from '@orpc/contract'
import { base } from '../base'
export type AccountProfileResponse = {
id: string
name: string
email: string
avatar: string
avatar_url: string | null
is_password_set: boolean
interface_language?: string
interface_theme?: string
timezone?: string
last_login_at?: string
last_active_at?: string
last_login_ip?: string
created_at?: string
}
export const accountProfileContract = base
.route({
path: '/account/profile',
method: 'GET',
})
.output(type<AccountProfileResponse>())
export const accountAvatarContract = base
.route({
path: '/account/avatar',
+5 -1
View File
@@ -1,7 +1,7 @@
import type { InferContractRouterInputs } from '@orpc/contract'
import { contract as communityContract } from '@dify/contracts/api/console/orpc.gen'
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { accountAvatarContract } from './console/account'
import { accountAvatarContract, accountProfileContract } from './console/account'
import { appDeleteContract, appListContract, workflowOnlineUsersContract } from './console/apps'
import { bindPartnerStackContract, invoicesContract } from './console/billing'
import {
@@ -75,6 +75,10 @@ export const consoleRouterContract = {
account: {
...communityContract.account,
avatar: accountAvatarContract,
profile: {
...communityContract.account.profile,
get: accountProfileContract,
},
},
systemFeatures: systemFeaturesContract,
apps: {
+1
View File
@@ -142,6 +142,7 @@ const clientSchema = {
export const env = createEnv({
server: {
CONSOLE_API_URL: z.string().optional(),
/**
* Maximum length of segmentation tokens for indexing
*/
@@ -0,0 +1,79 @@
import type { AccountProfileResponse } from '@/contract/console/account'
import { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { userProfileQueryOptions } from '../client'
import { resolveServerConsoleApiUrl } from '../server'
const headersMock = vi.fn()
const cookiesMock = vi.fn()
vi.mock('@/config/server', () => ({
SERVER_CONSOLE_API_PREFIX: undefined,
}))
vi.mock('@/next/headers', () => ({
headers: () => headersMock(),
cookies: () => cookiesMock(),
}))
const createProfile = (overrides: Partial<AccountProfileResponse> = {}): AccountProfileResponse => ({
id: 'account-id',
name: 'Dify User',
email: 'user@example.com',
avatar: '',
avatar_url: null,
is_password_set: true,
...overrides,
})
describe('serverUserProfileQueryOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
headersMock.mockResolvedValue(new Headers({ cookie: 'session=abc' }))
cookiesMock.mockResolvedValue({
get: vi.fn(() => ({ value: 'csrf-token' })),
})
})
it('should reuse the client profile query key and return the same data shape', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(createProfile()), {
status: 200,
headers: {
'content-type': 'application/json',
'x-version': '1.2.3',
'x-env': 'DEVELOPMENT',
},
}))
vi.stubGlobal('fetch', fetchMock)
const { serverUserProfileQueryOptions } = await import('../server')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const data = await queryClient.fetchQuery(serverUserProfileQueryOptions())
expect(serverUserProfileQueryOptions().queryKey).toEqual(userProfileQueryOptions().queryKey)
expect(data).toEqual({
profile: createProfile(),
meta: {
currentVersion: '1.2.3',
currentEnv: 'DEVELOPMENT',
},
})
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:5001/console/api/account/profile',
expect.objectContaining({
method: 'GET',
cache: 'no-store',
headers: expect.any(Headers),
}),
)
})
it('should skip relative API prefixes unless a server API origin is configured', () => {
expect(resolveServerConsoleApiUrl('/account/profile', undefined, '/console/api')).toBeNull()
expect(resolveServerConsoleApiUrl('/account/profile', 'https://console.example.com/console/api', '/console/api')).toBe('https://console.example.com/console/api/account/profile')
})
it('should preserve absolute API prefixes', () => {
expect(resolveServerConsoleApiUrl('/account/profile', undefined, 'https://console.example.com/console/api')).toBe('https://console.example.com/console/api/account/profile')
})
})
+41
View File
@@ -0,0 +1,41 @@
import type { AccountProfileResponse } from '@/contract/console/account'
import { queryOptions } from '@tanstack/react-query'
import { IS_DEV } from '@/config'
// eslint-disable-next-line no-restricted-imports
import { get } from '@/service/base'
import { consoleQuery } from '@/service/client'
export type UserProfileWithMeta = {
profile: AccountProfileResponse
meta: {
currentVersion: string | null
currentEnv: string | null
}
}
export const isLegacyBase401 = (err: unknown): boolean =>
err instanceof Response && err.status === 401
export const userProfileQueryOptions = () =>
queryOptions<UserProfileWithMeta>({
queryKey: consoleQuery.account.profile.get.queryKey(),
queryFn: async () => {
const response = await get<Response>('/account/profile', {}, {
needAllResponseContent: true,
silent: true,
})
const profile: AccountProfileResponse = await response.clone().json()
return {
profile,
meta: {
currentVersion: response.headers.get('x-version'),
currentEnv: IS_DEV
? 'DEVELOPMENT'
: response.headers.get('x-env'),
},
}
},
staleTime: 0,
gcTime: 0,
retry: (failureCount, error) => !isLegacyBase401(error) && failureCount < 3,
})
+81
View File
@@ -0,0 +1,81 @@
import type { UserProfileWithMeta } from './client'
import type { AccountProfileResponse } from '@/contract/console/account'
import { queryOptions } from '@tanstack/react-query'
import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config'
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
import { cookies, headers } from '@/next/headers'
import { consoleQuery } from '@/service/client'
const ACCOUNT_PROFILE_PATH = '/account/profile'
const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/`
const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value
const resolveAbsoluteUrlPrefix = (value: string) => {
try {
return new URL(value).toString()
}
catch {
return null
}
}
export const resolveServerConsoleApiUrl = (
pathname: string,
serverConsoleApiPrefix = SERVER_CONSOLE_API_PREFIX,
publicApiPrefix = API_PREFIX,
) => {
const requestPath = withoutLeadingSlash(pathname)
const apiPrefix = serverConsoleApiPrefix || resolveAbsoluteUrlPrefix(publicApiPrefix)
if (!apiPrefix)
return null
return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
}
const getServerRequestHeaders = async () => {
const requestHeaders = await headers()
const cookieStore = await cookies()
const outgoingHeaders = new Headers({
'Content-Type': 'application/json',
})
const cookie = requestHeaders.get('cookie')
if (cookie)
outgoingHeaders.set('cookie', cookie)
const csrfToken = cookieStore.get(CSRF_COOKIE_NAME())?.value
if (csrfToken)
outgoingHeaders.set(CSRF_HEADER_NAME, csrfToken)
return outgoingHeaders
}
export const serverUserProfileQueryOptions = () =>
queryOptions<UserProfileWithMeta>({
queryKey: consoleQuery.account.profile.get.queryKey(),
queryFn: async () => {
const profileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (!profileUrl)
throw new Error('Server account profile URL is not configured')
const response = await fetch(profileUrl, {
method: 'GET',
headers: await getServerRequestHeaders(),
cache: 'no-store',
})
if (!response.ok)
throw response
const profile: AccountProfileResponse = await response.clone().json()
return {
profile,
meta: {
currentVersion: response.headers.get('x-version'),
currentEnv: response.headers.get('x-env'),
},
}
},
staleTime: 0,
gcTime: 0,
retry: false,
})
+1
View File
@@ -135,6 +135,7 @@
"remark-breaks": "catalog:",
"remark-directive": "catalog:",
"scheduler": "catalog:",
"server-only": "catalog:",
"sharp": "catalog:",
"shiki": "catalog:",
"socket.io-client": "catalog:",
+5 -1
View File
@@ -6,6 +6,8 @@ import { NextResponse } from 'next/server'
import { env } from '@/env'
const NECESSARY_DOMAIN = '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://ungh.cc https://api2.amplitude.com *.amplitude.com'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
const CURRENT_SEARCH_HEADER = 'x-dify-search'
const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string) => {
// prevent clickjacking: https://owasp.org/www-community/attacks/Clickjacking
@@ -16,8 +18,10 @@ const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string)
return response
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const { pathname, search } = request.nextUrl
const requestHeaders = new Headers(request.headers)
requestHeaders.set(CURRENT_PATHNAME_HEADER, pathname)
requestHeaders.set(CURRENT_SEARCH_HEADER, search)
const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production'
if (!isWhiteListEnabled) {
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const createUnauthorizedResponse = () =>
new Response(JSON.stringify({
code: 'unauthorized',
message: 'Invalid Authorization token.',
status: 401,
}), {
status: 401,
headers: {
'Content-Type': 'application/json',
},
})
async function loadServerRequest() {
vi.resetModules()
const mockBaseFetch = vi.fn(async () => {
throw createUnauthorizedResponse()
})
const mockRefreshAccessTokenOrReLogin = vi.fn()
vi.doMock('@/utils/client', () => ({
isClient: false,
isServer: true,
}))
vi.doMock('../fetch', () => ({
base: mockBaseFetch,
ContentType: {
audio: 'audio/mpeg',
download: 'application/octet-stream',
downloadZip: 'application/zip',
json: 'application/json',
},
getBaseOptions: vi.fn(() => ({})),
}))
vi.doMock('../refresh-token', () => ({
refreshAccessTokenOrReLogin: mockRefreshAccessTokenOrReLogin,
}))
const { request } = await import('../base')
return {
request,
mockRefreshAccessTokenOrReLogin,
}
}
describe('request 401 handling', () => {
afterEach(() => {
vi.resetModules()
vi.restoreAllMocks()
})
it('should not run browser auth recovery when handling 401 on the server', async () => {
const { request, mockRefreshAccessTokenOrReLogin } = await loadServerRequest()
await expect(request('/account/profile')).rejects.toMatchObject({ status: 401 })
expect(mockRefreshAccessTokenOrReLogin).not.toHaveBeenCalled()
})
})
+26 -19
View File
@@ -31,6 +31,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import Cookies from 'js-cookie'
import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_CE_EDITION, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config'
import { asyncRunSafe } from '@/utils'
import { isClient } from '@/utils/client'
import { basePath } from '@/utils/var'
import { base, ContentType, getBaseOptions } from './fetch'
import { refreshAccessTokenOrReLogin } from './refresh-token'
@@ -132,22 +133,22 @@ export type IOtherOptions = {
}
function jumpTo(url: string) {
if (!url)
if (!url || !isClient)
return
const targetPath = new URL(url, globalThis.location.origin).pathname
if (targetPath === globalThis.location.pathname)
const targetPath = new URL(url, window.location.origin).pathname
if (targetPath === window.location.pathname)
return
globalThis.location.href = url
window.location.href = url
}
const OAUTH_AUTHORIZE_PATH = '/account/oauth/authorize'
export const buildSigninUrlWithRedirect = (): string => {
const loginUrl = `${globalThis.location.origin}${basePath}/signin`
const loginUrl = `${isClient ? window.location.origin : ''}${basePath}/signin`
// Only preserve redirect URL for OAuth authorize pages
if (globalThis.location.pathname.includes(OAUTH_AUTHORIZE_PATH)) {
const currentUrl = globalThis.location.href
if (isClient && window.location.pathname.includes(OAUTH_AUTHORIZE_PATH)) {
const currentUrl = window.location.href
return `${loginUrl}?redirect_url=${encodeURIComponent(currentUrl)}`
}
@@ -165,17 +166,20 @@ function unicodeToChar(text: string) {
const WBB_APP_LOGIN_PATH = '/webapp-signin'
function requiredWebSSOLogin(message?: string, code?: number) {
const params = new URLSearchParams()
// prevent redirect loop
if (globalThis.location.pathname === WBB_APP_LOGIN_PATH)
if (!isClient)
return
params.append('redirect_url', encodeURIComponent(`${globalThis.location.pathname}${globalThis.location.search}`))
const params = new URLSearchParams()
// prevent redirect loop
if (window.location.pathname === WBB_APP_LOGIN_PATH)
return
params.append('redirect_url', encodeURIComponent(`${window.location.pathname}${window.location.search}`))
if (message)
params.append('message', message)
if (code)
params.append('code', String(code))
globalThis.location.href = `${globalThis.location.origin}${basePath}${WBB_APP_LOGIN_PATH}?${params.toString()}`
window.location.href = `${window.location.origin}${basePath}${WBB_APP_LOGIN_PATH}?${params.toString()}`
}
function formatURL(url: string, isPublicAPI: boolean) {
@@ -759,10 +763,13 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
return resp
const errResp: Response = err as any
if (errResp.status === 401) {
if (!isClient)
return Promise.reject(err)
const [parseErr, errRespData] = await asyncRunSafe<ResponseError>(errResp.json())
const loginUrl = `${globalThis.location.origin}${basePath}/signin`
const loginUrl = `${window.location.origin}${basePath}/signin`
if (parseErr) {
globalThis.location.href = loginUrl
window.location.href = loginUrl
return Promise.reject(err)
}
if (/\/login/.test(url))
@@ -780,7 +787,7 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
}
if (code === 'unauthorized_and_force_logout') {
// Cookies will be cleared by the backend
globalThis.location.reload()
window.location.reload()
return Promise.reject(err)
}
const {
@@ -796,11 +803,11 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
return Promise.reject(err)
}
if (code === 'not_init_validated' && IS_CE_EDITION) {
jumpTo(`${globalThis.location.origin}${basePath}/init`)
jumpTo(`${window.location.origin}${basePath}/init`)
return Promise.reject(err)
}
if (code === 'not_setup' && IS_CE_EDITION) {
jumpTo(`${globalThis.location.origin}${basePath}/install`)
jumpTo(`${window.location.origin}${basePath}/install`)
return Promise.reject(err)
}
@@ -811,9 +818,9 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
// /device is the device-flow chooser; logged-out is a valid state
// there. Redirecting to /signin loses the user_code context and
// the post-login flow lands on /apps instead of returning here.
if (location.pathname === `${basePath}/device`)
if (window.location.pathname === `${basePath}/device`)
return Promise.reject(err)
if (location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) {
if (window.location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) {
jumpTo(buildSigninUrlWithRedirect())
return Promise.reject(err)
}
+4
View File
@@ -1,5 +1,6 @@
import { API_PREFIX } from '@/config'
import { fetchWithRetry } from '@/utils'
import { isClient } from '@/utils/client'
const LOCAL_STORAGE_KEY = 'is_other_tab_refreshing'
@@ -81,6 +82,9 @@ function releaseRefreshLock() {
}
export async function refreshAccessTokenOrReLogin(timeout: number) {
if (!isClient)
return Promise.reject(new Error('refresh token is client-only'))
return Promise.race([new Promise<void>((resolve, reject) => setTimeout(() => {
releaseRefreshLock()
reject(new Error('request timeout'))
+1 -57
View File
@@ -17,28 +17,15 @@ import type {
PluginProvider,
StructuredOutputRulesRequestBody,
StructuredOutputRulesResponse,
UserProfileResponse,
} from '@/models/common'
import type { RETRIEVE_METHOD } from '@/types/app'
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { IS_DEV } from '@/config'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { get, post } from './base'
/**
* True iff `err` is a 401 Response thrown by `service/base.ts`.
*
* Narrow on purpose: oRPC throws `ORPCError`, not `Response`, so this predicate
* returns `false` for oRPC 401s. Naming makes that scope visible. If you need
* 401 detection for an oRPC path, add a separate `isOrpc401` helper.
*/
export const isLegacyBase401 = (err: unknown): boolean =>
err instanceof Response && err.status === 401
const NAME_SPACE = 'common'
export const commonQueryKeys = {
fileUploadConfig: [NAME_SPACE, 'file-upload-config'] as const,
userProfile: [NAME_SPACE, 'user-profile'] as const,
currentWorkspace: [NAME_SPACE, 'current-workspace'] as const,
workspaces: [NAME_SPACE, 'workspaces'] as const,
members: [NAME_SPACE, 'members'] as const,
@@ -73,49 +60,6 @@ export const useFileUploadConfig = () => {
})
}
type UserProfileWithMeta = {
profile: UserProfileResponse
meta: {
currentVersion: string | null
currentEnv: string | null
}
}
/**
* Session probe for `/account/profile`. Helper (not hook) because oRPC can't
* express the `x-version` / `x-env` response headers we post-process.
*
* Bindings:
* commonLayout -> `useSuspenseQuery(userProfileQueryOptions())`
* signin/oauth -> `useQuery({ ...userProfileQueryOptions(), throwOnError: err => !isLegacyBase401(err) })`
*
* `silent: true` + `retry: !isLegacyBase401` makes 401 a synchronous *state* (no toast,
* no ~7s retry storm). Transient errors still get the default 3 retries.
*/
export const userProfileQueryOptions = () =>
queryOptions<UserProfileWithMeta>({
queryKey: commonQueryKeys.userProfile,
queryFn: async () => {
const response = await get<Response>('/account/profile', {}, {
needAllResponseContent: true,
silent: true,
}) as Response
const profile = await response.clone().json() as UserProfileResponse
return {
profile,
meta: {
currentVersion: response.headers.get('x-version'),
currentEnv: IS_DEV
? 'DEVELOPMENT'
: response.headers.get('x-env'),
},
}
},
staleTime: 0,
gcTime: 0,
retry: (failureCount, error) => !isLegacyBase401(error) && failureCount < 3,
})
export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: boolean) => {
return useQuery<LangGeniusVersionResponse>({
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
+106
View File
@@ -0,0 +1,106 @@
import { useSyncExternalStore } from 'react'
import { isClient } from './client'
const LOCAL_STORAGE_CHANGE_EVENT = 'dify-local-storage-change'
type LocalStorageChangeDetail = {
key: string
}
export const getLocalStorageItem = (key: string, fallback: string | null = null) => {
if (!isClient)
return fallback
try {
return window.localStorage.getItem(key) ?? fallback
}
catch {
return fallback
}
}
export const setLocalStorageItem = (key: string, value: string) => {
if (!isClient)
return
try {
window.localStorage.setItem(key, value)
window.dispatchEvent(new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
detail: { key },
}))
}
catch {
}
}
/* @public */
export const removeLocalStorageItem = (key: string) => {
if (!isClient)
return
try {
window.localStorage.removeItem(key)
window.dispatchEvent(new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
detail: { key },
}))
}
catch {
}
}
export const getLocalStorageBoolean = (key: string, fallback = false) => {
const value = getLocalStorageItem(key)
if (value === null)
return fallback
return value === 'true'
}
export const getLocalStorageNumber = (key: string, fallback: number) => {
const value = getLocalStorageItem(key)
if (!value)
return fallback
const parsed = Number.parseFloat(value)
return Number.isNaN(parsed) ? fallback : parsed
}
const subscribeLocalStorage = (key: string, onStoreChange: () => void) => {
if (!isClient)
return () => {}
const handleChange = (event: Event) => {
if (event instanceof StorageEvent && event.key !== key)
return
if (event instanceof CustomEvent && event.detail?.key !== key)
return
onStoreChange()
}
window.addEventListener('storage', handleChange)
window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleChange)
return () => {
window.removeEventListener('storage', handleChange)
window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleChange)
}
}
export const useLocalStorageItem = (key: string, fallback: string | null = null) => {
return useSyncExternalStore(
onStoreChange => subscribeLocalStorage(key, onStoreChange),
() => getLocalStorageItem(key, fallback),
() => fallback,
)
}
export const useLocalStorageBoolean = (key: string, fallback = false) => {
const value = useLocalStorageItem(key)
if (value === null)
return fallback
return value === 'true'
}
-139
View File
@@ -1,139 +0,0 @@
import type { SetupStatusResponse } from '@/models/common'
import { fetchSetupStatus } from '@/service/common'
import { fetchSetupStatusWithCache } from './setup-status'
vi.mock('@/service/common', () => ({
fetchSetupStatus: vi.fn(),
}))
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
describe('setup-status utilities', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
describe('fetchSetupStatusWithCache', () => {
describe('when cache exists', () => {
it('should return cached finished status without API call', async () => {
localStorage.setItem('setup_status', 'finished')
const result = await fetchSetupStatusWithCache()
expect(result).toEqual({ step: 'finished' })
expect(mockFetchSetupStatus).not.toHaveBeenCalled()
})
it('should not modify localStorage when returning cached value', async () => {
localStorage.setItem('setup_status', 'finished')
await fetchSetupStatusWithCache()
expect(localStorage.getItem('setup_status')).toBe('finished')
})
})
describe('when cache does not exist', () => {
it('should call API and cache finished status', async () => {
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBe('finished')
})
it('should call API and remove cache when not finished', async () => {
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
})
it('should clear stale cache when API returns not_started', async () => {
localStorage.setItem('setup_status', 'some_invalid_value')
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
})
})
describe('cache edge cases', () => {
it('should call API when cache value is empty string', async () => {
localStorage.setItem('setup_status', '')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
it('should call API when cache value is not "finished"', async () => {
localStorage.setItem('setup_status', 'not_started')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
it('should call API when localStorage key does not exist', async () => {
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
})
describe('API response handling', () => {
it('should preserve setup_at from API response', async () => {
const setupDate = new Date('2024-01-01')
const apiResponse: SetupStatusResponse = {
step: 'finished',
setup_at: setupDate,
}
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(result.setup_at).toEqual(setupDate)
})
it('should propagate API errors', async () => {
const apiError = new Error('Network error')
mockFetchSetupStatus.mockRejectedValue(apiError)
await expect(fetchSetupStatusWithCache()).rejects.toThrow('Network error')
})
it('should not update cache when API call fails', async () => {
mockFetchSetupStatus.mockRejectedValue(new Error('API error'))
await expect(fetchSetupStatusWithCache()).rejects.toThrow()
expect(localStorage.getItem('setup_status')).toBeNull()
})
})
})
})
-21
View File
@@ -1,21 +0,0 @@
import type { SetupStatusResponse } from '@/models/common'
import { fetchSetupStatus } from '@/service/common'
const SETUP_STATUS_KEY = 'setup_status'
const isSetupStatusCached = (): boolean =>
localStorage.getItem(SETUP_STATUS_KEY) === 'finished'
export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => {
if (isSetupStatusCached())
return { step: 'finished' }
const status = await fetchSetupStatus()
if (status.step === 'finished')
localStorage.setItem(SETUP_STATUS_KEY, 'finished')
else
localStorage.removeItem(SETUP_STATUS_KEY)
return status
}