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