diff --git a/e2e/features/step-definitions/apps/publish-app.steps.ts b/e2e/features/step-definitions/apps/publish-app.steps.ts
index de4f5ee63f..c426bc4c5a 100644
--- a/e2e/features/step-definitions/apps/publish-app.steps.ts
+++ b/e2e/features/step-definitions/apps/publish-app.steps.ts
@@ -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,
+ })
})
diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts
index 3ec038b065..c7acc91ebe 100644
--- a/e2e/features/step-definitions/apps/share-app.steps.ts
+++ b/e2e/features/step-definitions/apps/share-app.steps.ts
@@ -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 })
})
diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts
index 84c03bfa8f..c225591d69 100644
--- a/e2e/features/step-definitions/apps/workflow-run.steps.ts
+++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts
@@ -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 })
})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bceb0fe7ad..50127e1fd8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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'
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 4155780308..beaa275dfb 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -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
diff --git a/web/.env.example b/web/.env.example
index 81fff4275d..2684667cd4 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -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
diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx
index eb128924c0..fd651931b5 100644
--- a/web/__tests__/header/account-dropdown-flow.test.tsx
+++ b/web/__tests__/header/account-dropdown-flow.test.tsx
@@ -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()
diff --git a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx
new file mode 100644
index 0000000000..20dea97243
--- /dev/null
+++ b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx
@@ -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:
Common shell
,
+ })
+
+ render(
+
+ {element as ReactElement}
+ ,
+ )
+ 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: Common shell
,
+ })
+
+ render(
+
+ {element as ReactElement}
+ ,
+ )
+ expect(screen.getByText('Common shell')).toBeInTheDocument()
+ expect(mocks.profileQueryFn).not.toHaveBeenCalled()
+ expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx
index 83797b1fc5..a26d7057c7 100644
--- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx
+++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx
@@ -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 = (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(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 = (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])
diff --git a/web/app/(commonLayout)/education-apply/page.tsx b/web/app/(commonLayout)/education-apply/page.tsx
index 82e47d5c0b..e5d5a9a5fa 100644
--- a/web/app/(commonLayout)/education-apply/page.tsx
+++ b/web/app/(commonLayout)/education-apply/page.tsx
@@ -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
+ return
return
}
diff --git a/web/app/(commonLayout)/error.tsx b/web/app/(commonLayout)/error.tsx
index dbc5ded3e9..1548ffd741 100644
--- a/web/app/(commonLayout)/error.tsx
+++ b/web/app/(commonLayout)/error.tsx
@@ -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
+ return
return (
diff --git a/web/app/(commonLayout)/hydration-boundary.tsx b/web/app/(commonLayout)/hydration-boundary.tsx
new file mode 100644
index 0000000000..fe4cf49420
--- /dev/null
+++ b/web/app/(commonLayout)/hydration-boundary.tsx
@@ -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
=> {
+ 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 (
+
+ {children}
+
+ )
+ }
+
+ try {
+ await Promise.all([
+ queryClient.fetchQuery(serverUserProfileQueryOptions()),
+ queryClient.prefetchQuery(systemFeaturesQueryOptions()),
+ ])
+ }
+ catch (error) {
+ await handleProfileError(error)
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx
index 6f80c447b5..a92b83b752 100644
--- a/web/app/(commonLayout)/layout.tsx
+++ b/web/app/(commonLayout)/layout.tsx
@@ -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 (
<>
-
+
+
+
@@ -40,8 +44,8 @@ const Layout = ({ children }: { children: ReactNode }) => {
-
-
+
+
>
)
}
diff --git a/web/app/(commonLayout)/loading.tsx b/web/app/(commonLayout)/loading.tsx
deleted file mode 100644
index 3a5a14dc25..0000000000
--- a/web/app/(commonLayout)/loading.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import Loading from '@/app/components/base/loading'
-
-export default function CommonLayoutLoading() {
- return (
-
-
-
- )
-}
diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx
index 185c80fc20..35ad4df3e8 100644
--- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx
+++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx
@@ -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')
diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx
index b0719e169a..8f12906e3a 100644
--- a/web/app/account/(commonLayout)/account-page/index.tsx
+++ b/web/app/account/(commonLayout)/account-page/index.tsx
@@ -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('')
diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx
index 197a00f822..63e443f89d 100644
--- a/web/app/account/(commonLayout)/avatar.tsx
+++ b/web/app/account/(commonLayout)/avatar.tsx
@@ -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
diff --git a/web/app/account/(commonLayout)/layout.tsx b/web/app/account/(commonLayout)/layout.tsx
index 4d344c3f78..a97588d203 100644
--- a/web/app/account/(commonLayout)/layout.tsx
+++ b/web/app/account/(commonLayout)/layout.tsx
@@ -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 (
<>
-
+
+
+
@@ -30,7 +34,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
-
+
>
)
}
diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx
index 850fe9c2b5..af4b13dabb 100644
--- a/web/app/account/oauth/authorize/layout.tsx
+++ b/web/app/account/oauth/authorize/layout.tsx
@@ -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())
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx
index 97dabb46a9..d461794f8b 100644
--- a/web/app/account/oauth/authorize/page.tsx
+++ b/web/app/account/oauth/authorize/page.tsx
@@ -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)}`)
diff --git a/web/app/auth/refresh/__tests__/route.spec.ts b/web/app/auth/refresh/__tests__/route.spec.ts
new file mode 100644
index 0000000000..c86b6261a8
--- /dev/null
+++ b/web/app/auth/refresh/__tests__/route.spec.ts
@@ -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')
+ })
+})
diff --git a/web/app/auth/refresh/route.ts b/web/app/auth/refresh/route.ts
new file mode 100644
index 0000000000..998f5a5ffe
--- /dev/null
+++ b/web/app/auth/refresh/route.ts
@@ -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)
+ }
+}
diff --git a/web/app/components/__tests__/app-initializer.spec.tsx b/web/app/components/__tests__/app-initializer.spec.tsx
deleted file mode 100644
index b4c2d08f2e..0000000000
--- a/web/app/components/__tests__/app-initializer.spec.tsx
+++ /dev/null
@@ -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)
- mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType)
- mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' })
- mockResolvePostLoginRedirect.mockReturnValue(null)
- })
-
- it('renders children after setup checks finish', async () => {
- renderWithNuqs(
-
- ready
- ,
- )
-
- 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(
-
- ready
- ,
- )
-
- 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(
-
- ready
- ,
- )
-
- 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(
-
- ready
- ,
- { 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(
-
- ready
- ,
- { 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,
- )
-
- renderWithNuqs(
-
- ready
- ,
- )
-
- 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(
-
- ready
- ,
- )
-
- 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(
-
- ready
- ,
- )
-
- await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin'))
- expect(screen.queryByText('ready')).not.toBeInTheDocument()
- })
-})
diff --git a/web/app/components/__tests__/education-verify-action-recorder.spec.tsx b/web/app/components/__tests__/education-verify-action-recorder.spec.tsx
new file mode 100644
index 0000000000..416715abf2
--- /dev/null
+++ b/web/app/components/__tests__/education-verify-action-recorder.spec.tsx
@@ -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)
+ })
+
+ 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,
+ )
+
+ render()
+
+ await waitFor(() => {
+ expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
+ })
+ })
+
+ it('should leave localStorage unchanged for unrelated routes', () => {
+ render()
+
+ expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBeNull()
+ })
+})
diff --git a/web/app/components/__tests__/oauth-registration-analytics.spec.tsx b/web/app/components/__tests__/oauth-registration-analytics.spec.tsx
new file mode 100644
index 0000000000..6bc9fe4fe2
--- /dev/null
+++ b/web/app/components/__tests__/oauth-registration-analytics.spec.tsx
@@ -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)
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ await waitFor(() => {
+ expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
+ })
+ expect(mockTrackEvent).not.toHaveBeenCalled()
+ expect(mockSendGAEvent).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx
deleted file mode 100644
index 3d2af1ce61..0000000000
--- a/web/app/components/app-initializer.tsx
+++ /dev/null
@@ -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 :
-}
diff --git a/web/app/components/education-verify-action-recorder.tsx b/web/app/components/education-verify-action-recorder.tsx
new file mode 100644
index 0000000000..017bfa945e
--- /dev/null
+++ b/web/app/components/education-verify-action-recorder.tsx
@@ -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
+}
diff --git a/web/app/loading.tsx b/web/app/components/full-screen-loading.tsx
similarity index 60%
rename from web/app/loading.tsx
rename to web/app/components/full-screen-loading.tsx
index b108baaa97..a8be912850 100644
--- a/web/app/loading.tsx
+++ b/web/app/components/full-screen-loading.tsx
@@ -1,6 +1,6 @@
-import Loading from '@/app/components/base/loading'
+import Loading from './base/loading'
-export default function RootLoading() {
+export function FullScreenLoading() {
return (
diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx
index 107ebd7028..a894832936 100644
--- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx
@@ -277,7 +277,6 @@ describe('AccountDropdown', () => {
// Assert
await waitFor(() => {
expect(mockLogout).toHaveBeenCalled()
- expect(localStorage.removeItem).toHaveBeenCalledWith('setup_status')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})
diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx
index a64fc5f5a7..74794cdb51 100644
--- a/web/app/components/header/account-dropdown/index.tsx
+++ b/web/app/components/header/account-dropdown/index.tsx
@@ -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
diff --git a/web/app/components/header/header-wrapper.tsx b/web/app/components/header/header-wrapper.tsx
index 1379d176e0..e22f666c5b 100644
--- a/web/app/components/header/header-wrapper.tsx
+++ b/web/app/components/header/header-wrapper.tsx
@@ -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
(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 (
diff --git a/web/app/components/header/maintenance-notice.tsx b/web/app/components/header/maintenance-notice.tsx
index c60830c3cc..6d0adfbeba 100644
--- a/web/app/components/header/maintenance-notice.tsx
+++ b/web/app/components/header/maintenance-notice.tsx
@@ -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
diff --git a/web/app/components/oauth-registration-analytics.tsx b/web/app/components/oauth-registration-analytics.tsx
new file mode 100644
index 0000000000..73e90a1870
--- /dev/null
+++ b/web/app/components/oauth-registration-analytics.tsx
@@ -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 =>
+ 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(null)
+
+ useEffect(() => {
+ if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam)
+ return
+
+ handledParamRef.current = oauthNewUserParam
+ const oauthNewUser = oauthNewUserParam === 'true'
+ if (!oauthNewUser) {
+ removeOAuthNewUserParam()
+ return
+ }
+
+ let utmInfo: Record | 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
+}
diff --git a/web/app/components/workflow/store/workflow/layout-slice.ts b/web/app/components/workflow/store/workflow/layout-slice.ts
index eb81f12bac..fe5a3d1483 100644
--- a/web/app/components/workflow/store/workflow/layout-slice.ts
+++ b/web/app/components/workflow/store/workflow/layout-slice.ts
@@ -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 = 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 = 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 })),
})
diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts
index 09f08b68ba..9bace47b44 100644
--- a/web/app/components/workflow/store/workflow/panel-slice.ts
+++ b/web/app/components/workflow/store/workflow/panel-slice.ts
@@ -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 = 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,
diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts
index 58e3debc63..bbcd178da1 100644
--- a/web/app/components/workflow/store/workflow/workflow-slice.ts
+++ b/web/app/components/workflow/store/workflow/workflow-slice.ts
@@ -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 = 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 })),
diff --git a/web/app/device/__tests__/page-terminal.spec.tsx b/web/app/device/__tests__/page-terminal.spec.tsx
index 2ae450011e..cb69653ead 100644
--- a/web/app/device/__tests__/page-terminal.spec.tsx
+++ b/web/app/device/__tests__/page-terminal.spec.tsx
@@ -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'] },
}))
diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx
index 1033eb228e..aa09936b1f 100644
--- a/web/app/device/page.tsx
+++ b/web/app/device/page.tsx
@@ -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'
diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx
index be9b319038..b25d693a65 100644
--- a/web/app/education-apply/user-info.tsx
+++ b/web/app/education-apply/user-info.tsx
@@ -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')
diff --git a/web/app/error.tsx b/web/app/error.tsx
new file mode 100644
index 0000000000..33c2b9189c
--- /dev/null
+++ b/web/app/error.tsx
@@ -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
+
+ return (
+
+
+ {t('errorBoundary.message')}
+
+ {retry && (
+
+ )}
+
+ )
+}
diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx
index a9b8cc02be..65ad0b0df1 100644
--- a/web/app/install/installForm.spec.tsx
+++ b/web/app/install/installForm.spec.tsx
@@ -154,7 +154,6 @@ describe('InstallForm', () => {
render()
await waitFor(() => {
- expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})
diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx
index d9b6e0c7ad..20d2b26769 100644
--- a/web/app/install/installForm.tsx
+++ b/web/app/install/installForm.tsx
@@ -87,7 +87,6 @@ const InstallForm = () => {
useEffect(() => {
fetchSetupStatus().then((res: SetupStatusResponse) => {
if (res.step === 'finished') {
- localStorage.setItem('setup_status', 'finished')
router.push('/signin')
}
else {
diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx
index 76ac6b2ab4..23576837c0 100644
--- a/web/app/signin/normal-form.tsx
+++ b/web/app/signin/normal-form.tsx
@@ -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'
diff --git a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts
new file mode 100644
index 0000000000..00e270db2b
--- /dev/null
+++ b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts
@@ -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[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()
+ })
+})
diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts
index 2c8d82d9bb..363cd6bdf6 100644
--- a/web/app/signin/utils/post-login-redirect.ts
+++ b/web/app/signin/utils/post-login-redirect.ts
@@ -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> = {
'/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
}
diff --git a/web/config/server.ts b/web/config/server.ts
new file mode 100644
index 0000000000..388363642d
--- /dev/null
+++ b/web/config/server.ts
@@ -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
diff --git a/web/context/app-context-provider.tsx b/web/context/app-context-provider.tsx
index fb17664d6d..9f1db7362a 100644
--- a/web/context/app-context-provider.tsx
+++ b/web/context/app-context-provider.tsx
@@ -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 = ({ 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 = ({ 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(() => {
diff --git a/web/contract/console/account.ts b/web/contract/console/account.ts
index a8a468c40d..5e8e27e015 100644
--- a/web/contract/console/account.ts
+++ b/web/contract/console/account.ts
@@ -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())
+
export const accountAvatarContract = base
.route({
path: '/account/avatar',
diff --git a/web/contract/router.ts b/web/contract/router.ts
index 1987022551..e6b353a993 100644
--- a/web/contract/router.ts
+++ b/web/contract/router.ts
@@ -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: {
diff --git a/web/env.ts b/web/env.ts
index 0c2868be5c..16a0ea2da1 100644
--- a/web/env.ts
+++ b/web/env.ts
@@ -142,6 +142,7 @@ const clientSchema = {
export const env = createEnv({
server: {
+ CONSOLE_API_URL: z.string().optional(),
/**
* Maximum length of segmentation tokens for indexing
*/
diff --git a/web/features/account-profile/__tests__/server.spec.ts b/web/features/account-profile/__tests__/server.spec.ts
new file mode 100644
index 0000000000..79ed7fa571
--- /dev/null
+++ b/web/features/account-profile/__tests__/server.spec.ts
@@ -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 => ({
+ 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')
+ })
+})
diff --git a/web/features/account-profile/client.ts b/web/features/account-profile/client.ts
new file mode 100644
index 0000000000..636d3f8450
--- /dev/null
+++ b/web/features/account-profile/client.ts
@@ -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({
+ queryKey: consoleQuery.account.profile.get.queryKey(),
+ queryFn: async () => {
+ const response = await get('/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,
+ })
diff --git a/web/features/account-profile/server.ts b/web/features/account-profile/server.ts
new file mode 100644
index 0000000000..8885f5f15f
--- /dev/null
+++ b/web/features/account-profile/server.ts
@@ -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({
+ 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,
+ })
diff --git a/web/package.json b/web/package.json
index b8fbbd2fdb..8351a032ce 100644
--- a/web/package.json
+++ b/web/package.json
@@ -135,6 +135,7 @@
"remark-breaks": "catalog:",
"remark-directive": "catalog:",
"scheduler": "catalog:",
+ "server-only": "catalog:",
"sharp": "catalog:",
"shiki": "catalog:",
"socket.io-client": "catalog:",
diff --git a/web/proxy.ts b/web/proxy.ts
index d735c9f568..354f830619 100644
--- a/web/proxy.ts
+++ b/web/proxy.ts
@@ -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) {
diff --git a/web/service/__tests__/base-request.spec.ts b/web/service/__tests__/base-request.spec.ts
new file mode 100644
index 0000000000..658f73ee98
--- /dev/null
+++ b/web/service/__tests__/base-request.spec.ts
@@ -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()
+ })
+})
diff --git a/web/service/base.ts b/web/service/base.ts
index 7e35b3d789..9bd7be8704 100644
--- a/web/service/base.ts
+++ b/web/service/base.ts
@@ -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(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(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(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(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(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)
}
diff --git a/web/service/refresh-token.ts b/web/service/refresh-token.ts
index b00a46eb6e..3c69927f27 100644
--- a/web/service/refresh-token.ts
+++ b/web/service/refresh-token.ts
@@ -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((resolve, reject) => setTimeout(() => {
releaseRefreshLock()
reject(new Error('request timeout'))
diff --git a/web/service/use-common.ts b/web/service/use-common.ts
index 503bfa7a62..879abbb560 100644
--- a/web/service/use-common.ts
+++ b/web/service/use-common.ts
@@ -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({
- queryKey: commonQueryKeys.userProfile,
- queryFn: async () => {
- const response = await get('/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({
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
diff --git a/web/utils/local-storage.ts b/web/utils/local-storage.ts
new file mode 100644
index 0000000000..961a9bdd0a
--- /dev/null
+++ b/web/utils/local-storage.ts
@@ -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(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(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'
+}
diff --git a/web/utils/setup-status.spec.ts b/web/utils/setup-status.spec.ts
deleted file mode 100644
index be96b43eba..0000000000
--- a/web/utils/setup-status.spec.ts
+++ /dev/null
@@ -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()
- })
- })
- })
-})
diff --git a/web/utils/setup-status.ts b/web/utils/setup-status.ts
deleted file mode 100644
index 7a2810bffd..0000000000
--- a/web/utils/setup-status.ts
+++ /dev/null
@@ -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 => {
- 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
-}