Compare commits

...

19 Commits

Author SHA1 Message Date
Stephen Zhou
d9c23757d1 trigger ci 2026-03-19 13:37:56 +08:00
Stephen Zhou
a768fc37cf Merge branch 'main' into 3-18-no-global-loading 2026-03-19 13:26:06 +08:00
Stephen Zhou
b1ff105e8c tweaks 2026-03-19 11:28:28 +08:00
Stephen Zhou
4dc965aebf tweaks 2026-03-19 11:25:15 +08:00
Stephen Zhou
71a9125924 tweaks 2026-03-19 11:23:26 +08:00
Stephen Zhou
7fc0014d66 tweaks 2026-03-19 11:19:00 +08:00
autofix-ci[bot]
8e52267acb [autofix.ci] apply automated fixes 2026-03-19 03:17:32 +00:00
Stephen Zhou
b80e944665 tweaks 2026-03-19 11:14:20 +08:00
Stephen Zhou
60bae05d0f Merge branch 'main' into 3-18-no-global-loading 2026-03-19 10:11:15 +08:00
Stephen Zhou
a19cd375d4 tweaks 2026-03-18 23:53:39 +08:00
Stephen Zhou
8eb9f88c3b Merge branch 'main' into 3-18-no-global-loading 2026-03-18 23:48:09 +08:00
Stephen Zhou
8246985ad8 update 2026-03-18 20:00:57 +08:00
autofix-ci[bot]
63cae12bef [autofix.ci] apply automated fixes 2026-03-18 11:59:49 +00:00
Stephen Zhou
3dfa3fd5cd optimize deps 2026-03-18 19:56:54 +08:00
Stephen Zhou
af9c577bf6 optimize deps 2026-03-18 19:50:58 +08:00
Stephen Zhou
63eed30e78 ignore test for tailwind 2026-03-18 19:41:27 +08:00
Stephen Zhou
54d29cdf70 no redirects 2026-03-18 19:26:34 +08:00
autofix-ci[bot]
81022eb834 [autofix.ci] apply automated fixes 2026-03-18 11:20:07 +00:00
Stephen Zhou
1620e224bf refactor: no global loading 2026-03-18 18:09:23 +08:00
35 changed files with 785 additions and 199 deletions

View File

@@ -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()
})

View File

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

View File

@@ -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()
})

View File

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

View File

@@ -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()
})
})
})

View 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')
})
})
})

View File

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

View File

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

View 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()
})
})
})

View File

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

View File

@@ -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')}>

View File

@@ -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>
</>
)}

View File

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

View File

@@ -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,

View File

@@ -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) => {

View File

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

View File

@@ -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} />)

View File

@@ -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', () => ({

View File

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

View File

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

View File

@@ -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)

View File

@@ -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('/')
}

View File

@@ -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()

View File

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

View 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')
})
})
})

View File

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

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

View 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')
})
})
})

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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'] },

View File

@@ -1,4 +1,5 @@
export {
redirect,
useParams,
usePathname,
useRouter,

View File

@@ -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',
],
},
},
},
}
: {}),