mirror of
https://github.com/langgenius/dify.git
synced 2026-03-19 06:17:04 +00:00
Compare commits
19 Commits
refactor/w
...
3-18-no-gl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9c23757d1 | ||
|
|
a768fc37cf | ||
|
|
b1ff105e8c | ||
|
|
4dc965aebf | ||
|
|
71a9125924 | ||
|
|
7fc0014d66 | ||
|
|
8e52267acb | ||
|
|
b80e944665 | ||
|
|
60bae05d0f | ||
|
|
a19cd375d4 | ||
|
|
8eb9f88c3b | ||
|
|
8246985ad8 | ||
|
|
63cae12bef | ||
|
|
3dfa3fd5cd | ||
|
|
af9c577bf6 | ||
|
|
63eed30e78 | ||
|
|
54d29cdf70 | ||
|
|
81022eb834 | ||
|
|
1620e224bf |
@@ -55,7 +55,7 @@ describe('DatasetsLayout', () => {
|
||||
setAppContext()
|
||||
})
|
||||
|
||||
it('should render loading when workspace is still loading', () => {
|
||||
it('should keep rendering children when workspace is still loading', () => {
|
||||
setAppContext({
|
||||
isLoadingCurrentWorkspace: true,
|
||||
currentWorkspace: { id: '' },
|
||||
@@ -67,8 +67,7 @@ describe('DatasetsLayout', () => {
|
||||
</DatasetsLayout>
|
||||
))
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('datasets-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('datasets-content')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { ExternalApiPanelProvider } from '@/context/external-api-panel-context'
|
||||
import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context'
|
||||
@@ -19,9 +18,6 @@ export default function DatasetsLayout({ children }: { children: React.ReactNode
|
||||
router.replace('/apps')
|
||||
}, [shouldRedirect, router])
|
||||
|
||||
if (isLoadingCurrentWorkspace || !currentWorkspace.id)
|
||||
return <Loading type="app" />
|
||||
|
||||
if (shouldRedirect) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('RoleRouteGuard', () => {
|
||||
setAppContext()
|
||||
})
|
||||
|
||||
it('should render loading while workspace is loading', () => {
|
||||
it('should hide guarded content while workspace is loading', () => {
|
||||
setAppContext({
|
||||
isLoadingCurrentWorkspace: true,
|
||||
})
|
||||
@@ -52,7 +52,6 @@ describe('RoleRouteGuard', () => {
|
||||
</RoleRouteGuard>
|
||||
))
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
|
||||
@@ -22,9 +21,8 @@ export default function RoleRouteGuard({ children }: { children: ReactNode }) {
|
||||
router.replace('/datasets')
|
||||
}, [shouldRedirect, router])
|
||||
|
||||
// Block rendering only for guarded routes to avoid permission flicker.
|
||||
if (shouldGuardRoute && isLoadingCurrentWorkspace)
|
||||
return <Loading type="app" />
|
||||
return null
|
||||
|
||||
if (shouldRedirect)
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
|
||||
import AuthenticatedLayout from '../authenticated-layout'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockShareCode = 'share-code'
|
||||
const mockUpdateAppInfo = vi.fn()
|
||||
const mockUpdateAppParams = vi.fn()
|
||||
const mockUpdateWebAppMeta = vi.fn()
|
||||
const mockUpdateUserCanAccessApp = vi.fn()
|
||||
|
||||
const mockAppInfo = {
|
||||
app_id: 'app-123',
|
||||
}
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
usePathname: () => '/chat/test-share-code',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-share', () => ({
|
||||
useGetWebAppInfo: vi.fn(),
|
||||
useGetWebAppParams: vi.fn(),
|
||||
useGetWebAppMeta: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/webapp-auth', () => ({
|
||||
webAppLogout: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('AuthenticatedLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
shareCode: mockShareCode,
|
||||
updateAppInfo: mockUpdateAppInfo,
|
||||
updateAppParams: mockUpdateAppParams,
|
||||
updateWebAppMeta: mockUpdateWebAppMeta,
|
||||
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
|
||||
}
|
||||
return selector(state)
|
||||
})
|
||||
|
||||
;(useGetWebAppInfo as Mock).mockReturnValue({
|
||||
data: mockAppInfo,
|
||||
error: null,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
;(useGetWebAppParams as Mock).mockReturnValue({
|
||||
data: { user_input_form: [] },
|
||||
error: null,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
;(useGetWebAppMeta as Mock).mockReturnValue({
|
||||
data: { tool_icons: {} },
|
||||
error: null,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: true },
|
||||
error: null,
|
||||
isPending: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Gating', () => {
|
||||
it('should not render children while the app info needed for permission is still pending', () => {
|
||||
;(useGetWebAppInfo as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<AuthenticatedLayout>
|
||||
<div>protected child</div>
|
||||
</AuthenticatedLayout>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('protected child')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render children while the access check is still pending', () => {
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<AuthenticatedLayout>
|
||||
<div>protected child</div>
|
||||
</AuthenticatedLayout>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('protected child')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children once access is allowed even if metadata queries are still pending', () => {
|
||||
;(useGetWebAppParams as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
;(useGetWebAppMeta as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<AuthenticatedLayout>
|
||||
<div>protected child</div>
|
||||
</AuthenticatedLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('protected child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the no permission state when access is denied', () => {
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<AuthenticatedLayout>
|
||||
<div>protected child</div>
|
||||
</AuthenticatedLayout>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('protected child')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/no permission/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
123
web/app/(shareLayout)/components/__tests__/splash.spec.tsx
Normal file
123
web/app/(shareLayout)/components/__tests__/splash.spec.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import Splash from '../splash'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockWebAppLoginStatus = vi.fn()
|
||||
const mockFetchAccessToken = vi.fn()
|
||||
const mockSetWebAppAccessToken = vi.fn()
|
||||
const mockSetWebAppPassport = vi.fn()
|
||||
const mockWebAppLogout = vi.fn()
|
||||
|
||||
let mockShareCode: string | null = null
|
||||
let mockEmbeddedUserId: string | null = null
|
||||
let mockMessage: string | null = null
|
||||
let mockRedirectUrl: string | null = '/chat/test-share-code'
|
||||
let mockCode: string | null = null
|
||||
let mockTokenFromUrl: string | null = null
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: (selector: (state: { shareCode: string | null, embeddedUserId: string | null }) => unknown) =>
|
||||
selector({
|
||||
shareCode: mockShareCode,
|
||||
embeddedUserId: mockEmbeddedUserId,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'redirect_url')
|
||||
return mockRedirectUrl
|
||||
if (key === 'message')
|
||||
return mockMessage
|
||||
if (key === 'code')
|
||||
return mockCode
|
||||
if (key === 'web_sso_token')
|
||||
return mockTokenFromUrl
|
||||
return null
|
||||
},
|
||||
toString: () => {
|
||||
const params = new URLSearchParams()
|
||||
if (mockRedirectUrl)
|
||||
params.set('redirect_url', mockRedirectUrl)
|
||||
if (mockMessage)
|
||||
params.set('message', mockMessage)
|
||||
if (mockCode)
|
||||
params.set('code', mockCode)
|
||||
if (mockTokenFromUrl)
|
||||
params.set('web_sso_token', mockTokenFromUrl)
|
||||
return params.toString()
|
||||
},
|
||||
* [Symbol.iterator]() {
|
||||
const params = new URLSearchParams(this.toString())
|
||||
yield* params.entries()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', () => ({
|
||||
fetchAccessToken: (...args: unknown[]) => mockFetchAccessToken(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/webapp-auth', () => ({
|
||||
setWebAppAccessToken: (...args: unknown[]) => mockSetWebAppAccessToken(...args),
|
||||
setWebAppPassport: (...args: unknown[]) => mockSetWebAppPassport(...args),
|
||||
webAppLoginStatus: (...args: unknown[]) => mockWebAppLoginStatus(...args),
|
||||
webAppLogout: (...args: unknown[]) => mockWebAppLogout(...args),
|
||||
}))
|
||||
|
||||
describe('Share Splash', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockShareCode = null
|
||||
mockEmbeddedUserId = null
|
||||
mockMessage = null
|
||||
mockRedirectUrl = '/chat/test-share-code'
|
||||
mockCode = null
|
||||
mockTokenFromUrl = null
|
||||
mockWebAppLoginStatus.mockResolvedValue({
|
||||
userLoggedIn: true,
|
||||
appLoggedIn: true,
|
||||
})
|
||||
mockFetchAccessToken.mockResolvedValue({ access_token: 'token' })
|
||||
})
|
||||
|
||||
describe('Share Code Guard', () => {
|
||||
it('should skip login-status checks until the share code is available', async () => {
|
||||
render(
|
||||
<Splash>
|
||||
<div>share child</div>
|
||||
</Splash>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('share child')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockWebAppLoginStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mockFetchAccessToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should resume the auth flow after the share code becomes available', async () => {
|
||||
const { rerender } = render(
|
||||
<Splash>
|
||||
<div>share child</div>
|
||||
</Splash>,
|
||||
)
|
||||
|
||||
mockShareCode = 'share-code'
|
||||
rerender(
|
||||
<Splash>
|
||||
<div>share child</div>
|
||||
</Splash>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWebAppLoginStatus).toHaveBeenCalledWith('share-code', undefined)
|
||||
})
|
||||
expect(mockReplace).toHaveBeenCalledWith('/chat/test-share-code')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import * as React from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
@@ -18,10 +17,10 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const updateAppParams = useWebAppStore(s => s.updateAppParams)
|
||||
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
|
||||
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
|
||||
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetWebAppParams()
|
||||
const { isFetching: isFetchingAppInfo, data: appInfo, error: appInfoError } = useGetWebAppInfo()
|
||||
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetWebAppMeta()
|
||||
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false })
|
||||
const { data: appParams, error: appParamsError } = useGetWebAppParams()
|
||||
const { data: appInfo, error: appInfoError, isPending: isPendingAppInfo } = useGetWebAppInfo()
|
||||
const { data: appMeta, error: appMetaError } = useGetWebAppMeta()
|
||||
const { data: userCanAccessApp, error: useCanAccessAppError, isPending: isPendingUserCanAccessApp } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (appInfo)
|
||||
@@ -30,7 +29,8 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
updateAppParams(appParams)
|
||||
if (appMeta)
|
||||
updateWebAppMeta(appMeta)
|
||||
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result))
|
||||
if (userCanAccessApp)
|
||||
updateUserCanAccessApp(Boolean(userCanAccessApp.result))
|
||||
}, [appInfo, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp])
|
||||
|
||||
const router = useRouter()
|
||||
@@ -81,17 +81,14 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-y-2">
|
||||
<AppUnavailable className="h-auto w-auto" code={403} unknownReason="no permission." />
|
||||
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{t('userProfile.logout', { ns: 'common' })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading />
|
||||
<span className="cursor-pointer text-text-tertiary system-sm-regular" onClick={backToHome}>{t('userProfile.logout', { ns: 'common' })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPendingAppInfo || !appInfo?.app_id || isPendingUserCanAccessApp)
|
||||
return null
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
@@ -12,7 +11,6 @@ import { setWebAppAccessToken, setWebAppPassport, webAppLoginStatus, webAppLogou
|
||||
const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
const shareCode = useWebAppStore(s => s.shareCode)
|
||||
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
|
||||
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
@@ -28,17 +26,14 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
}, [searchParams])
|
||||
|
||||
const backToHome = useCallback(async () => {
|
||||
await webAppLogout(shareCode!)
|
||||
if (shareCode)
|
||||
await webAppLogout(shareCode)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router, shareCode])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
setIsLoading(false)
|
||||
if (message || !shareCode)
|
||||
return
|
||||
}
|
||||
|
||||
if (tokenFromUrl)
|
||||
setWebAppAccessToken(tokenFromUrl)
|
||||
@@ -46,38 +41,28 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
const redirectOrFinish = () => {
|
||||
if (redirectUrl)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
else
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const proceedToAuth = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// if access mode is public, user login is always true, but the app login(passport) may be expired
|
||||
const { userLoggedIn, appLoggedIn } = await webAppLoginStatus(shareCode!, embeddedUserId || undefined)
|
||||
const { userLoggedIn, appLoggedIn } = await webAppLoginStatus(shareCode, embeddedUserId || undefined)
|
||||
if (userLoggedIn && appLoggedIn) {
|
||||
redirectOrFinish()
|
||||
}
|
||||
else if (!userLoggedIn && !appLoggedIn) {
|
||||
proceedToAuth()
|
||||
}
|
||||
else if (!userLoggedIn && appLoggedIn) {
|
||||
redirectOrFinish()
|
||||
}
|
||||
else if (userLoggedIn && !appLoggedIn) {
|
||||
try {
|
||||
const { access_token } = await fetchAccessToken({
|
||||
appCode: shareCode!,
|
||||
appCode: shareCode,
|
||||
userId: embeddedUserId || undefined,
|
||||
})
|
||||
setWebAppPassport(shareCode!, access_token)
|
||||
setWebAppPassport(shareCode, access_token)
|
||||
redirectOrFinish()
|
||||
}
|
||||
catch {
|
||||
await webAppLogout(shareCode!)
|
||||
proceedToAuth()
|
||||
await webAppLogout(shareCode)
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -86,7 +71,6 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
redirectUrl,
|
||||
router,
|
||||
message,
|
||||
webAppAccessMode,
|
||||
tokenFromUrl,
|
||||
embeddedUserId,
|
||||
])
|
||||
@@ -95,18 +79,11 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-y-4">
|
||||
<AppUnavailable className="h-auto w-auto" code={code || t('common.appUnavailable', { ns: 'share' })} unknownReason={message} />
|
||||
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{code === '403' ? t('userProfile.logout', { ns: 'common' }) : t('login.backToHome', { ns: 'share' })}</span>
|
||||
<span className="cursor-pointer text-text-tertiary system-sm-regular" onClick={backToHome}>{code === '403' ? t('userProfile.logout', { ns: 'common' }) : t('login.backToHome', { ns: 'share' })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
|
||||
75
web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx
Normal file
75
web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import WebSSOForm from '../page'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
let mockRedirectUrl = '/share/test-share-code'
|
||||
let mockWebAppAccessMode: AccessMode | null = null
|
||||
let mockSystemFeaturesEnabled = true
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => key === 'redirect_url' ? mockRedirectUrl : null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) =>
|
||||
selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: mockSystemFeaturesEnabled,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: (selector: (state: { webAppAccessMode: AccessMode | null, shareCode: string | null }) => unknown) =>
|
||||
selector({
|
||||
webAppAccessMode: mockWebAppAccessMode,
|
||||
shareCode: 'test-share-code',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/webapp-auth', () => ({
|
||||
webAppLogout: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../normalForm', () => ({
|
||||
default: () => <div data-testid="normal-form" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/external-member-sso-auth', () => ({
|
||||
default: () => <div data-testid="external-member-sso-auth" />,
|
||||
}))
|
||||
|
||||
describe('WebSSOForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRedirectUrl = '/share/test-share-code'
|
||||
mockWebAppAccessMode = null
|
||||
mockSystemFeaturesEnabled = true
|
||||
})
|
||||
|
||||
describe('Access Mode Resolution', () => {
|
||||
it('should avoid rendering auth variants before the access mode query resolves', () => {
|
||||
render(<WebSSOForm />)
|
||||
|
||||
expect(screen.queryByTestId('normal-form')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('external-member-sso-auth')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('share.login.backToHome')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the normal form for organization-backed access modes', () => {
|
||||
mockWebAppAccessMode = AccessMode.ORGANIZATION
|
||||
|
||||
render(<WebSSOForm />)
|
||||
|
||||
expect(screen.getByTestId('normal-form')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -45,10 +45,13 @@ const WebSSOForm: FC = () => {
|
||||
if (!systemFeatures.webapp_auth.enabled) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="system-xs-regular text-text-tertiary">{t('webapp.disabled', { ns: 'login' })}</p>
|
||||
<p className="text-text-tertiary system-xs-regular">{t('webapp.disabled', { ns: 'login' })}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (webAppAccessMode === null)
|
||||
return <div className="w-full max-w-[400px]" />
|
||||
|
||||
if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) {
|
||||
return (
|
||||
<div className="w-full max-w-[400px]">
|
||||
@@ -63,7 +66,7 @@ const WebSSOForm: FC = () => {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-y-4">
|
||||
<AppUnavailable className="h-auto w-auto" isUnknownReason={true} />
|
||||
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{t('login.backToHome', { ns: 'share' })}</span>
|
||||
<span className="cursor-pointer text-text-tertiary system-sm-regular" onClick={backToHome}>{t('login.backToHome', { ns: 'share' })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
import Header from '@/app/signin/_header'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
@@ -11,16 +9,8 @@ import { cn } from '@/utils/classnames'
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
useDocumentTitle('')
|
||||
const { isLoading, data: loginData } = useIsLogin()
|
||||
const { data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full justify-center bg-background-default-burn">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/app/components/base/avatar'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
@@ -70,6 +69,7 @@ export default function OAuthAuthorize() {
|
||||
const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
const isLoading = isOAuthLoading || isIsLoginLoading
|
||||
const isActionDisabled = !client_id || !redirect_uri || isError || isLoading || authorizing
|
||||
const onLoginSwitchClick = () => {
|
||||
try {
|
||||
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)
|
||||
@@ -110,14 +110,6 @@ export default function OAuthAuthorize() {
|
||||
}
|
||||
}, [client_id, redirect_uri, isError])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background-default-subtle">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-default-subtle">
|
||||
{authAppInfo?.app_icon && (
|
||||
@@ -169,7 +161,7 @@ export default function OAuthAuthorize() {
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button variant="primary" size="large" className="w-full" onClick={onAuthorize} disabled={!client_id || !redirect_uri || isError || authorizing} loading={authorizing}>{t('continue', { ns: 'oauth' })}</Button>
|
||||
<Button variant="primary" size="large" className="w-full" onClick={onAuthorize} disabled={isActionDisabled} loading={authorizing}>{t('continue', { ns: 'oauth' })}</Button>
|
||||
<Button size="large" className="w-full" onClick={() => router.push('/apps')}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
@@ -25,7 +25,6 @@ export const AppInitializer = ({
|
||||
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' }),
|
||||
@@ -87,10 +86,7 @@ export const AppInitializer = ({
|
||||
const redirectUrl = resolvePostLoginRedirect()
|
||||
if (redirectUrl) {
|
||||
location.replace(redirectUrl)
|
||||
return
|
||||
}
|
||||
|
||||
setInit(true)
|
||||
}
|
||||
catch {
|
||||
router.replace('/signin')
|
||||
@@ -98,5 +94,5 @@ export const AppInitializer = ({
|
||||
})()
|
||||
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
|
||||
|
||||
return init ? children : null
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const usePSInfo = () => {
|
||||
}] = useBoolean(false)
|
||||
const { mutateAsync } = useBindPartnerStackInfo()
|
||||
// Save to top domain. cloud.dify.ai => .dify.ai
|
||||
const domain = globalThis.location.hostname.replace('cloud', '')
|
||||
const domain = globalThis.location?.hostname?.replace('cloud', '')
|
||||
|
||||
const saveOrUpdate = useCallback(() => {
|
||||
if (!psPartnerKey || !psClickId)
|
||||
@@ -37,9 +37,9 @@ const usePSInfo = () => {
|
||||
}), {
|
||||
expires: PARTNER_STACK_CONFIG.saveCookieDays,
|
||||
path: '/',
|
||||
domain,
|
||||
...(domain ? { domain } : {}),
|
||||
})
|
||||
}, [psPartnerKey, psClickId, isPSChanged])
|
||||
}, [psPartnerKey, psClickId, isPSChanged, domain])
|
||||
|
||||
const bind = useCallback(async () => {
|
||||
if (psPartnerKey && psClickId && !hasBind) {
|
||||
@@ -55,11 +55,15 @@ const usePSInfo = () => {
|
||||
if ((error as { status: number })?.status === 400)
|
||||
shouldRemoveCookie = true
|
||||
}
|
||||
if (shouldRemoveCookie)
|
||||
Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain })
|
||||
if (shouldRemoveCookie) {
|
||||
Cookies.remove(PARTNER_STACK_CONFIG.cookieName, {
|
||||
path: '/',
|
||||
...(domain ? { domain } : {}),
|
||||
})
|
||||
}
|
||||
setBind()
|
||||
}
|
||||
}, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind])
|
||||
}, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind, domain])
|
||||
return {
|
||||
psPartnerKey,
|
||||
psClickId,
|
||||
|
||||
@@ -10,6 +10,8 @@ type HeaderWrapperProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const getWorkflowCanvasMaximize = () => globalThis.localStorage?.getItem('workflow-canvas-maximize') === 'true'
|
||||
|
||||
const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
@@ -18,8 +20,7 @@ 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 [hideHeader, setHideHeader] = useState(getWorkflowCanvasMaximize)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
|
||||
@@ -3,16 +3,19 @@ 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'
|
||||
|
||||
const getShowNotice = () => globalThis.localStorage?.getItem('hide-maintenance-notice') !== '1'
|
||||
|
||||
const MaintenanceNotice = () => {
|
||||
const locale = useLanguage()
|
||||
|
||||
const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1')
|
||||
const [showNotice, setShowNotice] = useState(getShowNotice)
|
||||
|
||||
const handleJumpNotice = () => {
|
||||
window.open(NOTICE_I18N.href, '_blank')
|
||||
}
|
||||
|
||||
const handleCloseNotice = () => {
|
||||
localStorage.setItem('hide-maintenance-notice', '1')
|
||||
globalThis.localStorage?.setItem('hide-maintenance-notice', '1')
|
||||
setShowNotice(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import MenuDropdown from '../menu-dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockPathname = '/test-path'
|
||||
let mockWebAppAccessMode: AccessMode | null = AccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
@@ -16,7 +18,7 @@ const mockShareCode = 'test-share-code'
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
webAppAccessMode: 'code',
|
||||
webAppAccessMode: mockWebAppAccessMode,
|
||||
shareCode: mockShareCode,
|
||||
}
|
||||
return selector(state)
|
||||
@@ -41,6 +43,7 @@ describe('MenuDropdown', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWebAppAccessMode = AccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
@@ -151,6 +154,19 @@ describe('MenuDropdown', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide logout option when access mode is unknown', async () => {
|
||||
mockWebAppAccessMode = null
|
||||
|
||||
render(<MenuDropdown data={baseSiteInfo} hideLogout={false} />)
|
||||
|
||||
const triggerButton = screen.getByRole('button')
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('common.userProfile.logout')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call webAppLogout and redirect when logout is clicked', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} hideLogout={false} />)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { useTextGenerationAppState } from '../use-text-generation-app-state'
|
||||
|
||||
@@ -118,13 +119,13 @@ const defaultAppParams = {
|
||||
type MockWebAppState = {
|
||||
appInfo: MockAppInfo | null
|
||||
appParams: typeof defaultAppParams | null
|
||||
webAppAccessMode: string
|
||||
webAppAccessMode: AccessMode | null
|
||||
}
|
||||
|
||||
const mockWebAppState: MockWebAppState = {
|
||||
appInfo: defaultAppInfo,
|
||||
appParams: defaultAppParams,
|
||||
webAppAccessMode: 'public',
|
||||
webAppAccessMode: AccessMode.PUBLIC,
|
||||
}
|
||||
|
||||
const resetMockWebAppState = () => {
|
||||
@@ -154,7 +155,7 @@ const resetMockWebAppState = () => {
|
||||
image_file_size_limit: 10,
|
||||
},
|
||||
}
|
||||
mockWebAppState.webAppAccessMode = 'public'
|
||||
mockWebAppState.webAppAccessMode = AccessMode.PUBLIC
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
|
||||
@@ -58,6 +58,7 @@ const MenuDropdown: FC<Props> = ({
|
||||
}, [router, pathname, webAppLogout, shareCode])
|
||||
|
||||
const [show, setShow] = useState(false)
|
||||
const showLogout = !hideLogout && webAppAccessMode !== null && webAppAccessMode !== AccessMode.EXTERNAL_MEMBERS && webAppAccessMode !== AccessMode.PUBLIC
|
||||
|
||||
useEffect(() => {
|
||||
if (forceClose)
|
||||
@@ -85,7 +86,7 @@ const MenuDropdown: FC<Props> = ({
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-1">
|
||||
<div className={cn('system-md-regular flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary')}>
|
||||
<div className={cn('flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary system-md-regular')}>
|
||||
<div className="grow">{t('theme.theme', { ns: 'common' })}</div>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
@@ -93,7 +94,7 @@ const MenuDropdown: FC<Props> = ({
|
||||
<Divider type="horizontal" className="my-0" />
|
||||
<div className="p-1">
|
||||
{data?.privacy_policy && (
|
||||
<a href={data.privacy_policy} target="_blank" className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover">
|
||||
<a href={data.privacy_policy} target="_blank" className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover">
|
||||
<span className="grow">{t('chat.privacyPolicyMiddle', { ns: 'share' })}</span>
|
||||
</a>
|
||||
)}
|
||||
@@ -102,16 +103,16 @@ const MenuDropdown: FC<Props> = ({
|
||||
handleTrigger()
|
||||
setShow(true)
|
||||
}}
|
||||
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
className="cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
|
||||
>
|
||||
{t('userProfile.about', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
{!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && (
|
||||
{showLogout && (
|
||||
<div className="p-1">
|
||||
<div
|
||||
onClick={handleLogout}
|
||||
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
className="cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
|
||||
>
|
||||
{t('userProfile.logout', { ns: 'common' })}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import RunBatch from './run-batch'
|
||||
import RunOnce from './run-once'
|
||||
|
||||
type TextGenerationSidebarProps = {
|
||||
accessMode: AccessMode
|
||||
accessMode: AccessMode | null
|
||||
allTasksRun: boolean
|
||||
currentTab: string
|
||||
customConfig: TextGenerationCustomConfig | null
|
||||
|
||||
@@ -2,20 +2,15 @@
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useIsLogin } from '@/service/use-common'
|
||||
import Loading from './base/loading'
|
||||
|
||||
const Splash: FC<PropsWithChildren> = () => {
|
||||
// would auto redirect to signin page if not logged in
|
||||
const { isLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
|
||||
if (isLoading || !isLoggedIn) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999999] flex h-full items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isLoading || !isLoggedIn)
|
||||
return null
|
||||
|
||||
return null
|
||||
}
|
||||
export default React.memo(Splash)
|
||||
|
||||
@@ -47,7 +47,7 @@ const EducationApplyAge = () => {
|
||||
setShowModal(undefined)
|
||||
onPlanInfoChanged()
|
||||
updateEducationStatus()
|
||||
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
globalThis.localStorage?.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ const useEducationReverifyNotice = ({
|
||||
export const useEducationInit = () => {
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal)
|
||||
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
const educationVerifying = globalThis.localStorage?.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
const searchParams = useSearchParams()
|
||||
const educationVerifyAction = searchParams.get('action')
|
||||
|
||||
@@ -156,7 +156,7 @@ export const useEducationInit = () => {
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
|
||||
|
||||
if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
|
||||
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
||||
globalThis.localStorage?.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
||||
}
|
||||
if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION)
|
||||
handleVerify()
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Link from '@/next/link'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
const Home = async () => {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<Loading type="area" />
|
||||
<div className="mt-10 text-center">
|
||||
<Link href="/apps">🚀</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
export default async function Home() {
|
||||
redirect('/apps')
|
||||
}
|
||||
|
||||
export default Home
|
||||
|
||||
111
web/app/signin/invite-settings/__tests__/page.spec.tsx
Normal file
111
web/app/signin/invite-settings/__tests__/page.spec.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import InviteSettingsPage from '../page'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockActivateMember = vi.fn()
|
||||
const mockSetLocaleOnClient = vi.fn()
|
||||
const mockResolvePostLoginRedirect = vi.fn()
|
||||
|
||||
let mockInviteToken = 'invite-token'
|
||||
let mockCheckRes: {
|
||||
is_valid: boolean
|
||||
data: {
|
||||
workspace_name: string
|
||||
email: string
|
||||
workspace_id: string
|
||||
}
|
||||
} | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => key === 'invite_token' ? mockInviteToken : null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { branding: { enabled: boolean } } }) => unknown) =>
|
||||
selector({
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useInvitationCheck: () => ({
|
||||
data: mockCheckRes,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
activateMember: (...args: unknown[]) => mockActivateMember(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
setLocaleOnClient: (...args: unknown[]) => mockSetLocaleOnClient(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/post-login-redirect', () => ({
|
||||
resolvePostLoginRedirect: () => mockResolvePostLoginRedirect(),
|
||||
}))
|
||||
|
||||
describe('InviteSettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInviteToken = 'invite-token'
|
||||
mockCheckRes = undefined
|
||||
mockActivateMember.mockResolvedValue({ result: 'success' })
|
||||
mockSetLocaleOnClient.mockResolvedValue(undefined)
|
||||
mockResolvePostLoginRedirect.mockReturnValue('/apps')
|
||||
})
|
||||
|
||||
describe('Activation Gating', () => {
|
||||
it('should not activate when invitation validation is still pending and Enter is pressed', () => {
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
const nameInput = screen.getByLabelText('login.name')
|
||||
fireEvent.change(nameInput, { target: { value: 'Alice' } })
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||
|
||||
expect(mockActivateMember).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should activate when invitation validation has succeeded', async () => {
|
||||
mockCheckRes = {
|
||||
is_valid: true,
|
||||
data: {
|
||||
workspace_name: 'Demo Workspace',
|
||||
email: 'alice@example.com',
|
||||
workspace_id: 'workspace-1',
|
||||
},
|
||||
}
|
||||
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
const nameInput = screen.getByLabelText('login.name')
|
||||
fireEvent.change(nameInput, { target: { value: 'Alice' } })
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
url: '/activate',
|
||||
body: {
|
||||
token: 'invite-token',
|
||||
name: 'Alice',
|
||||
interface_language: 'en-US',
|
||||
timezone: expect.any(String),
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(mockSetLocaleOnClient).toHaveBeenCalledWith('en-US', false)
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,6 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { LICENSE_LINK } from '@/constants/link'
|
||||
@@ -37,9 +36,12 @@ export default function InviteSettingsPage() {
|
||||
},
|
||||
}
|
||||
const { data: checkRes, refetch: recheck } = useInvitationCheck(checkParams.params, !!token)
|
||||
const canActivate = checkRes?.is_valid === true
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
try {
|
||||
if (!canActivate)
|
||||
return
|
||||
if (!name) {
|
||||
Toast.notify({ type: 'error', message: t('enterYourName', { ns: 'login' }) })
|
||||
return
|
||||
@@ -63,11 +65,9 @@ export default function InviteSettingsPage() {
|
||||
catch {
|
||||
recheck()
|
||||
}
|
||||
}, [language, name, recheck, timezone, token, router, t])
|
||||
}, [canActivate, language, name, recheck, timezone, token, router, t])
|
||||
|
||||
if (!checkRes)
|
||||
return <Loading />
|
||||
if (!checkRes.is_valid) {
|
||||
if (checkRes?.is_valid === false) {
|
||||
return (
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="mx-auto w-full">
|
||||
@@ -107,7 +107,8 @@ export default function InviteSettingsPage() {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleActivate()
|
||||
if (canActivate)
|
||||
handleActivate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -147,8 +148,9 @@ export default function InviteSettingsPage() {
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleActivate}
|
||||
disabled={!canActivate}
|
||||
>
|
||||
{`${t('join', { ns: 'login' })} ${checkRes?.data?.workspace_name}`}
|
||||
{`${t('join', { ns: 'login' })} ${checkRes?.data?.workspace_name ?? ''}`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
74
web/context/__tests__/global-public-context.spec.tsx
Normal file
74
web/context/__tests__/global-public-context.spec.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import GlobalPublicStoreProvider, { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
|
||||
const mockSystemFeatures = vi.fn()
|
||||
const mockFetchSetupStatusWithCache = vi.fn()
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
systemFeatures: () => mockSystemFeatures(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/setup-status', () => ({
|
||||
fetchSetupStatusWithCache: () => mockFetchSetupStatusWithCache(),
|
||||
}))
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderProvider = () => {
|
||||
const queryClient = createQueryClient()
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GlobalPublicStoreProvider>
|
||||
<div>provider child</div>
|
||||
</GlobalPublicStoreProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('GlobalPublicStoreProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useGlobalPublicStore.setState({ systemFeatures: defaultSystemFeatures })
|
||||
mockFetchSetupStatusWithCache.mockResolvedValue({ setup_status: 'finished' })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children when system features are still loading', async () => {
|
||||
mockSystemFeatures.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderProvider()
|
||||
|
||||
expect(screen.getByText('provider child')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockSystemFeatures).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Updates', () => {
|
||||
it('should update the public store when system features query succeeds', async () => {
|
||||
mockSystemFeatures.mockResolvedValue({
|
||||
...defaultSystemFeatures,
|
||||
enable_marketplace: true,
|
||||
})
|
||||
|
||||
renderProvider()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useGlobalPublicStore.getState().systemFeatures.enable_marketplace).toBe(true)
|
||||
})
|
||||
expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
108
web/context/__tests__/web-app-context.spec.tsx
Normal file
108
web/context/__tests__/web-app-context.spec.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
|
||||
let mockPathname = '/share/test-share-code'
|
||||
let mockRedirectUrl: string | null = null
|
||||
let mockAccessModeResult: { accessMode: AccessMode } | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => key === 'redirect_url' ? mockRedirectUrl : null,
|
||||
toString: () => {
|
||||
const params = new URLSearchParams()
|
||||
if (mockRedirectUrl)
|
||||
params.set('redirect_url', mockRedirectUrl)
|
||||
return params.toString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/utils', () => ({
|
||||
getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-share', () => ({
|
||||
useGetWebAppAccessModeByCode: vi.fn(() => ({
|
||||
data: mockAccessModeResult,
|
||||
})),
|
||||
}))
|
||||
|
||||
const StoreSnapshot = () => {
|
||||
const shareCode = useWebAppStore(s => s.shareCode)
|
||||
const accessMode = useWebAppStore(s => s.webAppAccessMode)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="share-code">{shareCode ?? 'none'}</span>
|
||||
<span data-testid="access-mode">{accessMode ?? 'unknown'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WebAppStoreProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPathname = '/share/test-share-code'
|
||||
mockRedirectUrl = null
|
||||
mockAccessModeResult = undefined
|
||||
useWebAppStore.setState({
|
||||
shareCode: null,
|
||||
appInfo: null,
|
||||
appParams: null,
|
||||
webAppAccessMode: null,
|
||||
appMeta: null,
|
||||
userCanAccessApp: false,
|
||||
embeddedUserId: null,
|
||||
embeddedConversationId: null,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Access Mode State', () => {
|
||||
it('should keep the access mode unknown until the query resolves', async () => {
|
||||
render(
|
||||
<WebAppStoreProvider>
|
||||
<StoreSnapshot />
|
||||
</WebAppStoreProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('share-code')).toHaveTextContent('test-share-code')
|
||||
})
|
||||
expect(screen.getByTestId('access-mode')).toHaveTextContent('unknown')
|
||||
})
|
||||
|
||||
it('should reset the access mode when the share code changes before the next result arrives', async () => {
|
||||
const { rerender } = render(
|
||||
<WebAppStoreProvider>
|
||||
<StoreSnapshot />
|
||||
</WebAppStoreProvider>,
|
||||
)
|
||||
|
||||
mockAccessModeResult = { accessMode: AccessMode.PUBLIC }
|
||||
rerender(
|
||||
<WebAppStoreProvider>
|
||||
<StoreSnapshot />
|
||||
</WebAppStoreProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('access-mode')).toHaveTextContent(AccessMode.PUBLIC)
|
||||
})
|
||||
|
||||
mockPathname = '/share/next-share-code'
|
||||
mockAccessModeResult = undefined
|
||||
rerender(
|
||||
<WebAppStoreProvider>
|
||||
<StoreSnapshot />
|
||||
</WebAppStoreProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('share-code')).toHaveTextContent('next-share-code')
|
||||
})
|
||||
expect(screen.getByTestId('access-mode')).toHaveTextContent('unknown')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import type { FC, PropsWithChildren } from 'react'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { create } from 'zustand'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { consoleClient } from '@/service/client'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
@@ -53,13 +52,11 @@ const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
|
||||
}) => {
|
||||
// Fetch systemFeatures and setupStatus in parallel to reduce waterfall.
|
||||
// setupStatus is prefetched here and cached in localStorage for AppInitializer.
|
||||
const { isPending } = useSystemFeaturesQuery()
|
||||
useSystemFeaturesQuery()
|
||||
|
||||
// Prefetch setupStatus for AppInitializer (result not needed here)
|
||||
useSetupStatusQuery()
|
||||
|
||||
if (isPending)
|
||||
return <div className="flex h-screen w-screen items-center justify-center"><Loading /></div>
|
||||
return <>{children}</>
|
||||
}
|
||||
export default GlobalPublicStoreProvider
|
||||
|
||||
@@ -106,10 +106,10 @@ export const ModalContextProvider = ({
|
||||
|
||||
const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
|
||||
const handleCancelAccountSettingModal = () => {
|
||||
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
const educationVerifying = globalThis.localStorage?.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
|
||||
if (educationVerifying === 'yes')
|
||||
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
globalThis.localStorage?.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
|
||||
accountSettingCallbacksRef.current?.onCancelCallback?.()
|
||||
accountSettingCallbacksRef.current = null
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import type { AppData, AppMeta } from '@/models/share'
|
||||
import { useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { usePathname, useSearchParams } from '@/next/navigation'
|
||||
import { useGetWebAppAccessModeByCode } from '@/service/use-share'
|
||||
import { useIsSystemFeaturesPending } from './global-public-context'
|
||||
|
||||
type WebAppStore = {
|
||||
shareCode: string | null
|
||||
@@ -19,8 +17,8 @@ type WebAppStore = {
|
||||
updateAppInfo: (appInfo: AppData | null) => void
|
||||
appParams: ChatConfig | null
|
||||
updateAppParams: (appParams: ChatConfig | null) => void
|
||||
webAppAccessMode: AccessMode
|
||||
updateWebAppAccessMode: (accessMode: AccessMode) => void
|
||||
webAppAccessMode: AccessMode | null
|
||||
updateWebAppAccessMode: (accessMode: AccessMode | null) => void
|
||||
appMeta: AppMeta | null
|
||||
updateWebAppMeta: (appMeta: AppMeta | null) => void
|
||||
userCanAccessApp: boolean
|
||||
@@ -38,8 +36,8 @@ export const useWebAppStore = create<WebAppStore>(set => ({
|
||||
updateAppInfo: (appInfo: AppData | null) => set(() => ({ appInfo })),
|
||||
appParams: null,
|
||||
updateAppParams: (appParams: ChatConfig | null) => set(() => ({ appParams })),
|
||||
webAppAccessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
updateWebAppAccessMode: (accessMode: AccessMode) => set(() => ({ webAppAccessMode: accessMode })),
|
||||
webAppAccessMode: null,
|
||||
updateWebAppAccessMode: (accessMode: AccessMode | null) => set(() => ({ webAppAccessMode: accessMode })),
|
||||
appMeta: null,
|
||||
updateWebAppMeta: (appMeta: AppMeta | null) => set(() => ({ appMeta })),
|
||||
userCanAccessApp: false,
|
||||
@@ -65,7 +63,6 @@ const getShareCodeFromPathname = (pathname: string): string | null => {
|
||||
}
|
||||
|
||||
const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const isGlobalPending = useIsSystemFeaturesPending()
|
||||
const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode)
|
||||
const updateShareCode = useWebAppStore(state => state.updateShareCode)
|
||||
const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId)
|
||||
@@ -81,6 +78,10 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
updateShareCode(shareCode)
|
||||
}, [shareCode, updateShareCode])
|
||||
|
||||
useEffect(() => {
|
||||
updateWebAppAccessMode(null)
|
||||
}, [shareCode, updateWebAppAccessMode])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const syncEmbeddedUserId = async () => {
|
||||
@@ -104,24 +105,13 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
}
|
||||
}, [searchParamsString, updateEmbeddedUserId, updateEmbeddedConversationId])
|
||||
|
||||
const { isLoading, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode)
|
||||
const { data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode)
|
||||
|
||||
useEffect(() => {
|
||||
if (accessModeResult?.accessMode)
|
||||
updateWebAppAccessMode(accessModeResult.accessMode)
|
||||
}, [accessModeResult, updateWebAppAccessMode, shareCode])
|
||||
}, [accessModeResult, updateWebAppAccessMode])
|
||||
|
||||
if (isGlobalPending || isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
return <>{children}</>
|
||||
}
|
||||
export default WebAppStoreProvider
|
||||
|
||||
@@ -175,19 +175,6 @@
|
||||
"count": 18
|
||||
}
|
||||
},
|
||||
"app/(shareLayout)/components/authenticated-layout.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/(shareLayout)/components/splash.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/(shareLayout)/webapp-reset-password/check-code/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -267,11 +254,6 @@
|
||||
"count": 18
|
||||
}
|
||||
},
|
||||
"app/(shareLayout)/webapp-signin/page.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/account/(commonLayout)/account-page/AvatarWithEdit.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
@@ -5921,9 +5903,6 @@
|
||||
},
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/share/text-generation/no-data/index.tsx": {
|
||||
|
||||
@@ -21,15 +21,6 @@ const nextConfig: NextConfig = {
|
||||
// https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/apps',
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
output: 'standalone',
|
||||
compiler: {
|
||||
removeConsole: isDev ? false : { exclude: ['warn', 'error'] },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
redirect,
|
||||
useParams,
|
||||
usePathname,
|
||||
useRouter,
|
||||
|
||||
@@ -70,6 +70,28 @@ export default defineConfig(({ mode }) => {
|
||||
// SyntaxError: Named export not found. The requested module is a CommonJS module, which may not support all module.exports as named exports
|
||||
noExternal: ['emoji-mart'],
|
||||
},
|
||||
environments: {
|
||||
rsc: {
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'lamejs',
|
||||
'lamejs/src/js/BitStream',
|
||||
'lamejs/src/js/Lame',
|
||||
'lamejs/src/js/MPEGMode',
|
||||
],
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'lamejs',
|
||||
'lamejs/src/js/BitStream',
|
||||
'lamejs/src/js/Lame',
|
||||
'lamejs/src/js/MPEGMode',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user