diff --git a/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx b/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx new file mode 100644 index 0000000000..d20525998c --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx @@ -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: () =>
, +})) + +vi.mock('../components/external-member-sso-auth', () => ({ + default: () =>
, +})) + +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() + + 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() + + expect(screen.getByTestId('normal-form')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index a5c2528cc7..c62cb06365 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -45,10 +45,13 @@ const WebSSOForm: FC = () => { if (!systemFeatures.webapp_auth.enabled) { return (
-

{t('webapp.disabled', { ns: 'login' })}

+

{t('webapp.disabled', { ns: 'login' })}

) } + if (webAppAccessMode === null) + return
+ if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) { return (
@@ -63,7 +66,7 @@ const WebSSOForm: FC = () => { return (
- {t('login.backToHome', { ns: 'share' })} + {t('login.backToHome', { ns: 'share' })}
) } diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx index 7ccd788cb0..bcf715abc2 100644 --- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx @@ -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) => 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() + + 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() diff --git a/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts b/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts index 3339dce403..4dfa279689 100644 --- a/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts +++ b/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts @@ -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', () => ({ diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index 49633890d3..b3ee2088d1 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -58,6 +58,7 @@ const MenuDropdown: FC = ({ }, [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 = ({
-
+
{t('theme.theme', { ns: 'common' })}
@@ -93,7 +94,7 @@ const MenuDropdown: FC = ({
{data?.privacy_policy && ( - + {t('chat.privacyPolicyMiddle', { ns: 'share' })} )} @@ -102,16 +103,16 @@ const MenuDropdown: FC = ({ 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' })}
- {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( + {showLogout && (
{t('userProfile.logout', { ns: 'common' })}
diff --git a/web/app/components/share/text-generation/text-generation-sidebar.tsx b/web/app/components/share/text-generation/text-generation-sidebar.tsx index 70b65f59e9..c07d61faf6 100644 --- a/web/app/components/share/text-generation/text-generation-sidebar.tsx +++ b/web/app/components/share/text-generation/text-generation-sidebar.tsx @@ -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 diff --git a/web/context/__tests__/web-app-context.spec.tsx b/web/context/__tests__/web-app-context.spec.tsx new file mode 100644 index 0000000000..8dbcb06d25 --- /dev/null +++ b/web/context/__tests__/web-app-context.spec.tsx @@ -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 ( +
+ {shareCode ?? 'none'} + {accessMode ?? 'unknown'} +
+ ) +} + +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( + + + , + ) + + 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( + + + , + ) + + mockAccessModeResult = { accessMode: AccessMode.PUBLIC } + rerender( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('access-mode')).toHaveTextContent(AccessMode.PUBLIC) + }) + + mockPathname = '/share/next-share-code' + mockAccessModeResult = undefined + rerender( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('share-code')).toHaveTextContent('next-share-code') + }) + expect(screen.getByTestId('access-mode')).toHaveTextContent('unknown') + }) + }) +}) diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx index e3971c7e08..d8d5619f0d 100644 --- a/web/context/web-app-context.tsx +++ b/web/context/web-app-context.tsx @@ -2,11 +2,11 @@ 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 { AccessMode } from '@/models/access-control' import { usePathname, useSearchParams } from '@/next/navigation' import { useGetWebAppAccessModeByCode } from '@/service/use-share' @@ -17,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 @@ -36,8 +36,8 @@ export const useWebAppStore = create(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, @@ -78,6 +78,10 @@ const WebAppStoreProvider: FC = ({ children }) => { updateShareCode(shareCode) }, [shareCode, updateShareCode]) + useEffect(() => { + updateWebAppAccessMode(null) + }, [shareCode, updateWebAppAccessMode]) + useEffect(() => { let cancelled = false const syncEmbeddedUserId = async () => { @@ -106,7 +110,7 @@ const WebAppStoreProvider: FC = ({ children }) => { useEffect(() => { if (accessModeResult?.accessMode) updateWebAppAccessMode(accessModeResult.accessMode) - }, [accessModeResult, updateWebAppAccessMode, shareCode]) + }, [accessModeResult, updateWebAppAccessMode]) return <>{children} }