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 -}