From 29afc0657db4f8eef2c07c83fbe0bbc170acc074 Mon Sep 17 00:00:00 2001
From: crazywoola <100913391+crazywoola@users.noreply.github.com>
Date: Tue, 28 Oct 2025 09:19:54 +0800
Subject: [PATCH] Fix/27468 in dify 192 the iframe embed cannot pass the user
id in system variable (#27524)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
web/__tests__/embedded-user-id-auth.test.tsx | 132 +++++++++++++++
web/__tests__/embedded-user-id-store.test.tsx | 155 ++++++++++++++++++
web/app/(shareLayout)/components/splash.tsx | 9 +-
.../webapp-signin/check-code/page.tsx | 7 +-
.../components/mail-and-password-auth.tsx | 7 +-
.../base/chat/embedded-chatbot/hooks.tsx | 14 +-
web/context/web-app-context.tsx | 36 ++++
7 files changed, 351 insertions(+), 9 deletions(-)
create mode 100644 web/__tests__/embedded-user-id-auth.test.tsx
create mode 100644 web/__tests__/embedded-user-id-store.test.tsx
diff --git a/web/__tests__/embedded-user-id-auth.test.tsx b/web/__tests__/embedded-user-id-auth.test.tsx
new file mode 100644
index 0000000000..5c3c3c943f
--- /dev/null
+++ b/web/__tests__/embedded-user-id-auth.test.tsx
@@ -0,0 +1,132 @@
+import React from 'react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+
+import MailAndPasswordAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-password-auth'
+import CheckCode from '@/app/(shareLayout)/webapp-signin/check-code/page'
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+const replaceMock = jest.fn()
+const backMock = jest.fn()
+
+jest.mock('next/navigation', () => ({
+ usePathname: jest.fn(() => '/chatbot/test-app'),
+ useRouter: jest.fn(() => ({
+ replace: replaceMock,
+ back: backMock,
+ })),
+ useSearchParams: jest.fn(),
+}))
+
+const mockStoreState = {
+ embeddedUserId: 'embedded-user-99',
+ shareCode: 'test-app',
+}
+
+const useWebAppStoreMock = jest.fn((selector?: (state: typeof mockStoreState) => any) => {
+ return selector ? selector(mockStoreState) : mockStoreState
+})
+
+jest.mock('@/context/web-app-context', () => ({
+ useWebAppStore: (selector?: (state: typeof mockStoreState) => any) => useWebAppStoreMock(selector),
+}))
+
+const webAppLoginMock = jest.fn()
+const webAppEmailLoginWithCodeMock = jest.fn()
+const sendWebAppEMailLoginCodeMock = jest.fn()
+
+jest.mock('@/service/common', () => ({
+ webAppLogin: (...args: any[]) => webAppLoginMock(...args),
+ webAppEmailLoginWithCode: (...args: any[]) => webAppEmailLoginWithCodeMock(...args),
+ sendWebAppEMailLoginCode: (...args: any[]) => sendWebAppEMailLoginCodeMock(...args),
+}))
+
+const fetchAccessTokenMock = jest.fn()
+
+jest.mock('@/service/share', () => ({
+ fetchAccessToken: (...args: any[]) => fetchAccessTokenMock(...args),
+}))
+
+const setWebAppAccessTokenMock = jest.fn()
+const setWebAppPassportMock = jest.fn()
+
+jest.mock('@/service/webapp-auth', () => ({
+ setWebAppAccessToken: (...args: any[]) => setWebAppAccessTokenMock(...args),
+ setWebAppPassport: (...args: any[]) => setWebAppPassportMock(...args),
+ webAppLogout: jest.fn(),
+}))
+
+jest.mock('@/app/components/signin/countdown', () => () =>
)
+
+jest.mock('@remixicon/react', () => ({
+ RiMailSendFill: () => ,
+ RiArrowLeftLine: () => ,
+}))
+
+const { useSearchParams } = jest.requireMock('next/navigation') as {
+ useSearchParams: jest.Mock
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+describe('embedded user id propagation in authentication flows', () => {
+ it('passes embedded user id when logging in with email and password', async () => {
+ const params = new URLSearchParams()
+ params.set('redirect_url', encodeURIComponent('/chatbot/test-app'))
+ useSearchParams.mockReturnValue(params)
+
+ webAppLoginMock.mockResolvedValue({ result: 'success', data: { access_token: 'login-token' } })
+ fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' })
+
+ render()
+
+ fireEvent.change(screen.getByLabelText('login.email'), { target: { value: 'user@example.com' } })
+ fireEvent.change(screen.getByLabelText(/login\.password/), { target: { value: 'strong-password' } })
+ fireEvent.click(screen.getByRole('button', { name: 'login.signBtn' }))
+
+ await waitFor(() => {
+ expect(fetchAccessTokenMock).toHaveBeenCalledWith({
+ appCode: 'test-app',
+ userId: 'embedded-user-99',
+ })
+ })
+ expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('login-token')
+ expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token')
+ expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
+ })
+
+ it('passes embedded user id when verifying email code', async () => {
+ const params = new URLSearchParams()
+ params.set('redirect_url', encodeURIComponent('/chatbot/test-app'))
+ params.set('email', encodeURIComponent('user@example.com'))
+ params.set('token', encodeURIComponent('token-abc'))
+ useSearchParams.mockReturnValue(params)
+
+ webAppEmailLoginWithCodeMock.mockResolvedValue({ result: 'success', data: { access_token: 'code-token' } })
+ fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' })
+
+ render()
+
+ fireEvent.change(
+ screen.getByPlaceholderText('login.checkCode.verificationCodePlaceholder'),
+ { target: { value: '123456' } },
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'login.checkCode.verify' }))
+
+ await waitFor(() => {
+ expect(fetchAccessTokenMock).toHaveBeenCalledWith({
+ appCode: 'test-app',
+ userId: 'embedded-user-99',
+ })
+ })
+ expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('code-token')
+ expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token')
+ expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
+ })
+})
diff --git a/web/__tests__/embedded-user-id-store.test.tsx b/web/__tests__/embedded-user-id-store.test.tsx
new file mode 100644
index 0000000000..24a815222e
--- /dev/null
+++ b/web/__tests__/embedded-user-id-store.test.tsx
@@ -0,0 +1,155 @@
+import React from 'react'
+import { render, screen, waitFor } from '@testing-library/react'
+
+import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
+
+jest.mock('next/navigation', () => ({
+ usePathname: jest.fn(() => '/chatbot/sample-app'),
+ useSearchParams: jest.fn(() => {
+ const params = new URLSearchParams()
+ return params
+ }),
+}))
+
+jest.mock('@/service/use-share', () => {
+ const { AccessMode } = jest.requireActual('@/models/access-control')
+ return {
+ useGetWebAppAccessModeByCode: jest.fn(() => ({
+ isLoading: false,
+ data: { accessMode: AccessMode.PUBLIC },
+ })),
+ }
+})
+
+jest.mock('@/app/components/base/chat/utils', () => ({
+ getProcessedSystemVariablesFromUrlParams: jest.fn(),
+}))
+
+const { getProcessedSystemVariablesFromUrlParams: mockGetProcessedSystemVariablesFromUrlParams }
+ = jest.requireMock('@/app/components/base/chat/utils') as {
+ getProcessedSystemVariablesFromUrlParams: jest.Mock
+ }
+
+jest.mock('@/context/global-public-context', () => {
+ const mockGlobalStoreState = {
+ isGlobalPending: false,
+ setIsGlobalPending: jest.fn(),
+ systemFeatures: {},
+ setSystemFeatures: jest.fn(),
+ }
+ const useGlobalPublicStore = Object.assign(
+ (selector?: (state: typeof mockGlobalStoreState) => any) =>
+ selector ? selector(mockGlobalStoreState) : mockGlobalStoreState,
+ {
+ setState: (updater: any) => {
+ if (typeof updater === 'function')
+ Object.assign(mockGlobalStoreState, updater(mockGlobalStoreState) ?? {})
+
+ else
+ Object.assign(mockGlobalStoreState, updater)
+ },
+ __mockState: mockGlobalStoreState,
+ },
+ )
+ return {
+ useGlobalPublicStore,
+ }
+})
+
+const {
+ useGlobalPublicStore: useGlobalPublicStoreMock,
+} = jest.requireMock('@/context/global-public-context') as {
+ useGlobalPublicStore: ((selector?: (state: any) => any) => any) & {
+ setState: (updater: any) => void
+ __mockState: {
+ isGlobalPending: boolean
+ setIsGlobalPending: jest.Mock
+ systemFeatures: Record
+ setSystemFeatures: jest.Mock
+ }
+ }
+}
+const mockGlobalStoreState = useGlobalPublicStoreMock.__mockState
+
+const TestConsumer = () => {
+ const embeddedUserId = useWebAppStore(state => state.embeddedUserId)
+ const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId)
+ return (
+ <>
+ {embeddedUserId ?? 'null'}
+ {embeddedConversationId ?? 'null'}
+ >
+ )
+}
+
+const initialWebAppStore = (() => {
+ const snapshot = useWebAppStore.getState()
+ return {
+ shareCode: null as string | null,
+ appInfo: null,
+ appParams: null,
+ webAppAccessMode: snapshot.webAppAccessMode,
+ appMeta: null,
+ userCanAccessApp: false,
+ embeddedUserId: null,
+ embeddedConversationId: null,
+ updateShareCode: snapshot.updateShareCode,
+ updateAppInfo: snapshot.updateAppInfo,
+ updateAppParams: snapshot.updateAppParams,
+ updateWebAppAccessMode: snapshot.updateWebAppAccessMode,
+ updateWebAppMeta: snapshot.updateWebAppMeta,
+ updateUserCanAccessApp: snapshot.updateUserCanAccessApp,
+ updateEmbeddedUserId: snapshot.updateEmbeddedUserId,
+ updateEmbeddedConversationId: snapshot.updateEmbeddedConversationId,
+ }
+})()
+
+beforeEach(() => {
+ mockGlobalStoreState.isGlobalPending = false
+ mockGetProcessedSystemVariablesFromUrlParams.mockReset()
+ useWebAppStore.setState(initialWebAppStore, true)
+})
+
+describe('WebAppStoreProvider embedded user id handling', () => {
+ it('hydrates embedded user and conversation ids from system variables', async () => {
+ mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({
+ user_id: 'iframe-user-123',
+ conversation_id: 'conversation-456',
+ })
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('embedded-user-id')).toHaveTextContent('iframe-user-123')
+ expect(screen.getByTestId('embedded-conversation-id')).toHaveTextContent('conversation-456')
+ })
+ expect(useWebAppStore.getState().embeddedUserId).toBe('iframe-user-123')
+ expect(useWebAppStore.getState().embeddedConversationId).toBe('conversation-456')
+ })
+
+ it('clears embedded user id when system variable is absent', async () => {
+ useWebAppStore.setState(state => ({
+ ...state,
+ embeddedUserId: 'previous-user',
+ embeddedConversationId: 'existing-conversation',
+ }))
+ mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('embedded-user-id')).toHaveTextContent('null')
+ expect(screen.getByTestId('embedded-conversation-id')).toHaveTextContent('null')
+ })
+ expect(useWebAppStore.getState().embeddedUserId).toBeNull()
+ expect(useWebAppStore.getState().embeddedConversationId).toBeNull()
+ })
+})
diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx
index 16d291d4b4..c30ad68950 100644
--- a/web/app/(shareLayout)/components/splash.tsx
+++ b/web/app/(shareLayout)/components/splash.tsx
@@ -15,6 +15,7 @@ const Splash: FC = ({ 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()
const redirectUrl = searchParams.get('redirect_url')
@@ -69,7 +70,10 @@ const Splash: FC = ({ children }) => {
}
else if (userLoggedIn && !appLoggedIn) {
try {
- const { access_token } = await fetchAccessToken({ appCode: shareCode! })
+ const { access_token } = await fetchAccessToken({
+ appCode: shareCode!,
+ userId: embeddedUserId || undefined,
+ })
setWebAppPassport(shareCode!, access_token)
redirectOrFinish()
}
@@ -85,7 +89,8 @@ const Splash: FC = ({ children }) => {
router,
message,
webAppAccessMode,
- tokenFromUrl])
+ tokenFromUrl,
+ embeddedUserId])
if (message) {
return
diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx
index 4a1326fedf..69131cdabe 100644
--- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx
+++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx
@@ -12,6 +12,7 @@ import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/co
import I18NContext from '@/context/i18n'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
import { fetchAccessToken } from '@/service/share'
+import { useWebAppStore } from '@/context/web-app-context'
export default function CheckCode() {
const { t } = useTranslation()
@@ -23,6 +24,7 @@ export default function CheckCode() {
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const redirectUrl = searchParams.get('redirect_url')
+ const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const getAppCodeFromRedirectUrl = useCallback(() => {
if (!redirectUrl)
@@ -63,7 +65,10 @@ export default function CheckCode() {
const ret = await webAppEmailLoginWithCode({ email, code, token })
if (ret.result === 'success') {
setWebAppAccessToken(ret.data.access_token)
- const { access_token } = await fetchAccessToken({ appCode: appCode! })
+ const { access_token } = await fetchAccessToken({
+ appCode: appCode!,
+ userId: embeddedUserId || undefined,
+ })
setWebAppPassport(appCode!, access_token)
router.replace(decodeURIComponent(redirectUrl))
}
diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx
index ce220b103e..0136445ac9 100644
--- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx
+++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx
@@ -10,6 +10,7 @@ import { emailRegex } from '@/config'
import { webAppLogin } from '@/service/common'
import Input from '@/app/components/base/input'
import I18NContext from '@/context/i18n'
+import { useWebAppStore } from '@/context/web-app-context'
import { noop } from 'lodash-es'
import { fetchAccessToken } from '@/service/share'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
@@ -30,6 +31,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
const [isLoading, setIsLoading] = useState(false)
const redirectUrl = searchParams.get('redirect_url')
+ const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const getAppCodeFromRedirectUrl = useCallback(() => {
if (!redirectUrl)
@@ -82,7 +84,10 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
if (res.result === 'success') {
setWebAppAccessToken(res.data.access_token)
- const { access_token } = await fetchAccessToken({ appCode: appCode! })
+ const { access_token } = await fetchAccessToken({
+ appCode: appCode!,
+ userId: embeddedUserId || undefined,
+ })
setWebAppPassport(appCode!, access_token)
router.replace(decodeURIComponent(redirectUrl))
}
diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx
index cfb221522c..9a9abfbd09 100644
--- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx
@@ -66,16 +66,20 @@ export const useEmbeddedChatbot = () => {
const appInfo = useWebAppStore(s => s.appInfo)
const appMeta = useWebAppStore(s => s.appMeta)
const appParams = useWebAppStore(s => s.appParams)
+ const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId)
+ const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const appId = useMemo(() => appInfo?.app_id, [appInfo])
const [userId, setUserId] = useState
()
const [conversationId, setConversationId] = useState()
+
useEffect(() => {
- getProcessedSystemVariablesFromUrlParams().then(({ user_id, conversation_id }) => {
- setUserId(user_id)
- setConversationId(conversation_id)
- })
- }, [])
+ setUserId(embeddedUserId || undefined)
+ }, [embeddedUserId])
+
+ useEffect(() => {
+ setConversationId(embeddedConversationId || undefined)
+ }, [embeddedConversationId])
useEffect(() => {
const setLanguageFromParams = async () => {
diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx
index bcbd39b5fc..1b189cd452 100644
--- a/web/context/web-app-context.tsx
+++ b/web/context/web-app-context.tsx
@@ -9,6 +9,7 @@ import { usePathname, useSearchParams } from 'next/navigation'
import type { FC, PropsWithChildren } from 'react'
import { useEffect } from 'react'
import { create } from 'zustand'
+import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils'
import { useGlobalPublicStore } from './global-public-context'
type WebAppStore = {
@@ -24,6 +25,10 @@ type WebAppStore = {
updateWebAppMeta: (appMeta: AppMeta | null) => void
userCanAccessApp: boolean
updateUserCanAccessApp: (canAccess: boolean) => void
+ embeddedUserId: string | null
+ updateEmbeddedUserId: (userId: string | null) => void
+ embeddedConversationId: string | null
+ updateEmbeddedConversationId: (conversationId: string | null) => void
}
export const useWebAppStore = create(set => ({
@@ -39,6 +44,11 @@ export const useWebAppStore = create(set => ({
updateWebAppMeta: (appMeta: AppMeta | null) => set(() => ({ appMeta })),
userCanAccessApp: false,
updateUserCanAccessApp: (canAccess: boolean) => set(() => ({ userCanAccessApp: canAccess })),
+ embeddedUserId: null,
+ updateEmbeddedUserId: (userId: string | null) => set(() => ({ embeddedUserId: userId })),
+ embeddedConversationId: null,
+ updateEmbeddedConversationId: (conversationId: string | null) =>
+ set(() => ({ embeddedConversationId: conversationId })),
}))
const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => {
@@ -58,9 +68,12 @@ const WebAppStoreProvider: FC = ({ children }) => {
const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending)
const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode)
const updateShareCode = useWebAppStore(state => state.updateShareCode)
+ const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId)
+ const updateEmbeddedConversationId = useWebAppStore(state => state.updateEmbeddedConversationId)
const pathname = usePathname()
const searchParams = useSearchParams()
const redirectUrlParam = searchParams.get('redirect_url')
+ const searchParamsString = searchParams.toString()
// Compute shareCode directly
const shareCode = getShareCodeFromRedirectUrl(redirectUrlParam) || getShareCodeFromPathname(pathname)
@@ -68,6 +81,29 @@ const WebAppStoreProvider: FC = ({ children }) => {
updateShareCode(shareCode)
}, [shareCode, updateShareCode])
+ useEffect(() => {
+ let cancelled = false
+ const syncEmbeddedUserId = async () => {
+ try {
+ const { user_id, conversation_id } = await getProcessedSystemVariablesFromUrlParams()
+ if (!cancelled) {
+ updateEmbeddedUserId(user_id || null)
+ updateEmbeddedConversationId(conversation_id || null)
+ }
+ }
+ catch {
+ if (!cancelled) {
+ updateEmbeddedUserId(null)
+ updateEmbeddedConversationId(null)
+ }
+ }
+ }
+ syncEmbeddedUserId()
+ return () => {
+ cancelled = true
+ }
+ }, [searchParamsString, updateEmbeddedUserId, updateEmbeddedConversationId])
+
const { isLoading, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode)
useEffect(() => {