diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx new file mode 100644 index 0000000000..7fe55c3060 --- /dev/null +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -0,0 +1,30 @@ +'use client' + +import Loading from '@/app/components/base/loading' +import { useWebAppStore } from '@/context/web-app-context' +import React, { useEffect, useState } from 'react' + +const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { + const shareCode = useWebAppStore(s => s.shareCode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) + const [isLoading, setIsLoading] = useState(true) + useEffect(() => { + (async () => { + try { + setIsLoading(true) + } + catch (error) { console.error(error) } + finally { + setIsLoading(false) + } + })() + }, [webAppAccessMode, shareCode]) + if (isLoading) { + return
+ +
+ } + return <>{children} +} + +export default React.memo(AuthenticatedLayout) diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx new file mode 100644 index 0000000000..4fe9efe4dd --- /dev/null +++ b/web/app/(shareLayout)/components/splash.tsx @@ -0,0 +1,80 @@ +'use client' +import type { FC, PropsWithChildren } from 'react' +import { useEffect } from 'react' +import { useCallback } from 'react' +import { useWebAppStore } from '@/context/web-app-context' +import { useRouter, useSearchParams } from 'next/navigation' +import AppUnavailable from '@/app/components/base/app-unavailable' +import { checkOrSetAccessToken, removeAccessToken, setAccessToken } from '@/app/components/share/utils' +import { useTranslation } from 'react-i18next' +import { fetchAccessToken } from '@/service/share' +import Loading from '@/app/components/base/loading' +import { AccessMode } from '@/models/access-control' + +const Splash: FC = ({ children }) => { + const { t } = useTranslation() + const shareCode = useWebAppStore(s => s.shareCode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) + const searchParams = useSearchParams() + const router = useRouter() + const redirectUrl = searchParams.get('redirect_url') + const tokenFromUrl = searchParams.get('web_sso_token') + const message = searchParams.get('message') + const code = searchParams.get('code') + const getSigninUrl = useCallback(() => { + const params = new URLSearchParams(searchParams) + params.delete('message') + params.delete('code') + return `/webapp-signin?${params.toString()}` + }, [searchParams]) + + const backToHome = useCallback(() => { + removeAccessToken() + const url = getSigninUrl() + router.replace(url) + }, [getSigninUrl, router]) + + useEffect(() => { + (async () => { + if (message) + return + if (shareCode && tokenFromUrl && redirectUrl) { + localStorage.setItem('webapp_access_token', tokenFromUrl) + const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: tokenFromUrl }) + await setAccessToken(shareCode, tokenResp.access_token) + router.replace(decodeURIComponent(redirectUrl)) + return + } + if (shareCode && redirectUrl && localStorage.getItem('webapp_access_token')) { + const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: localStorage.getItem('webapp_access_token') }) + await setAccessToken(shareCode, tokenResp.access_token) + router.replace(decodeURIComponent(redirectUrl)) + return + } + if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) { + await checkOrSetAccessToken(shareCode) + router.replace(decodeURIComponent(redirectUrl)) + } + })() + }, [shareCode, redirectUrl, router, tokenFromUrl, message, webAppAccessMode]) + + if (message) { + return
+ + {code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')} +
+ } + if (tokenFromUrl) { + return
+ +
+ } + if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) { + return
+ +
+ } + return <>{children} +} + +export default Splash diff --git a/web/app/(shareLayout)/layout.tsx b/web/app/(shareLayout)/layout.tsx index d057ba7599..5af913cac9 100644 --- a/web/app/(shareLayout)/layout.tsx +++ b/web/app/(shareLayout)/layout.tsx @@ -1,54 +1,15 @@ -'use client' -import React, { useEffect, useState } from 'react' -import type { FC } from 'react' -import { usePathname, useSearchParams } from 'next/navigation' -import Loading from '../components/base/loading' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { AccessMode } from '@/models/access-control' -import { getAppAccessModeByAppCode } from '@/service/share' +import type { FC, PropsWithChildren } from 'react' +import WebAppStoreProvider from '@/context/web-app-context' +import Splash from './components/splash' -const Layout: FC<{ - children: React.ReactNode -}> = ({ children }) => { - const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending) - const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const pathname = usePathname() - const searchParams = useSearchParams() - const redirectUrl = searchParams.get('redirect_url') - const [isLoading, setIsLoading] = useState(true) - useEffect(() => { - (async () => { - if (!isGlobalPending && !systemFeatures.webapp_auth.enabled) { - setIsLoading(false) - return - } - - let appCode: string | null = null - if (redirectUrl) { - const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) - appCode = url.pathname.split('/').pop() || null - } - else { - appCode = pathname.split('/').pop() || null - } - - if (!appCode) - return - setIsLoading(true) - const ret = await getAppAccessModeByAppCode(appCode) - setWebAppAccessMode(ret?.accessMode || AccessMode.PUBLIC) - setIsLoading(false) - })() - }, [pathname, redirectUrl, setWebAppAccessMode, isGlobalPending, systemFeatures.webapp_auth.enabled]) - if (isLoading || isGlobalPending) { - return
- -
- } +const Layout: FC = ({ children }) => { return (
- {children} + + + {children} + +
) } diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index a03364d326..7649982072 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -3,10 +3,13 @@ import cn from '@/utils/classnames' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import type { PropsWithChildren } from 'react' +import { useTranslation } from 'react-i18next' -export default function SignInLayout({ children }: any) { - const { systemFeatures } = useGlobalPublicStore() - useDocumentTitle('') +export default function SignInLayout({ children }: PropsWithChildren) { + const { t } = useTranslation() + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + useDocumentTitle(t('login.webapp.login')) return <>
diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index d6bdf607ba..44006a9f1e 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -1,3 +1,4 @@ +'use client' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Link from 'next/link' diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 967516c416..1c6209b902 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -1,36 +1,30 @@ 'use client' import { useRouter, useSearchParams } from 'next/navigation' import type { FC } from 'react' -import React, { useCallback, useEffect } from 'react' +import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Toast from '@/app/components/base/toast' -import { removeAccessToken, setAccessToken } from '@/app/components/share/utils' +import { removeAccessToken } from '@/app/components/share/utils' import { useGlobalPublicStore } from '@/context/global-public-context' -import Loading from '@/app/components/base/loading' import AppUnavailable from '@/app/components/base/app-unavailable' import NormalForm from './normalForm' import { AccessMode } from '@/models/access-control' import ExternalMemberSsoAuth from './components/external-member-sso-auth' -import { fetchAccessToken } from '@/service/share' +import { useWebAppStore } from '@/context/web-app-context' const WebSSOForm: FC = () => { const { t } = useTranslation() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const searchParams = useSearchParams() const router = useRouter() const redirectUrl = searchParams.get('redirect_url') - const tokenFromUrl = searchParams.get('web_sso_token') - const message = searchParams.get('message') - const code = searchParams.get('code') const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.delete('code') + const params = new URLSearchParams() + params.append('redirect_url', redirectUrl || '') return `/webapp-signin?${params.toString()}` - }, [searchParams]) + }, [redirectUrl]) const backToHome = useCallback(() => { removeAccessToken() @@ -38,73 +32,12 @@ const WebSSOForm: FC = () => { router.replace(url) }, [getSigninUrl, router]) - const showErrorToast = (msg: string) => { - Toast.notify({ - type: 'error', - message: msg, - }) - } - - const getAppCodeFromRedirectUrl = useCallback(() => { - if (!redirectUrl) - return null - const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) - const appCode = url.pathname.split('/').pop() - if (!appCode) - return null - - return appCode - }, [redirectUrl]) - - useEffect(() => { - (async () => { - if (message) - return - - const appCode = getAppCodeFromRedirectUrl() - if (appCode && tokenFromUrl && redirectUrl) { - localStorage.setItem('webapp_access_token', tokenFromUrl) - const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: tokenFromUrl }) - await setAccessToken(appCode, tokenResp.access_token) - router.replace(decodeURIComponent(redirectUrl)) - return - } - if (appCode && redirectUrl && localStorage.getItem('webapp_access_token')) { - const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: localStorage.getItem('webapp_access_token') }) - await setAccessToken(appCode, tokenResp.access_token) - router.replace(decodeURIComponent(redirectUrl)) - } - })() - }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message]) - - useEffect(() => { - if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl) - router.replace(decodeURIComponent(redirectUrl)) - }, [webAppAccessMode, router, redirectUrl]) - - if (tokenFromUrl) { - return
- -
- } - - if (message) { - return
- - {code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')} -
- } if (!redirectUrl) { - showErrorToast('redirect url is invalid.') return
} - if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) { - return
- -
- } + if (!systemFeatures.webapp_auth.enabled) { return

{t('login.webapp.disabled')}

diff --git a/web/app/(shareLayout)/workflow/[token]/page.tsx b/web/app/(shareLayout)/workflow/[token]/page.tsx index e93bc8c1af..4f5923e91f 100644 --- a/web/app/(shareLayout)/workflow/[token]/page.tsx +++ b/web/app/(shareLayout)/workflow/[token]/page.tsx @@ -1,10 +1,13 @@ import React from 'react' import Main from '@/app/components/share/text-generation' +import AuthenticatedLayout from '../../components/authenticated-layout' const Workflow = () => { return ( -
+ +
+ ) } diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 4be6b18958..6397b57785 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -9,7 +9,7 @@ import { import { useBoolean } from 'ahooks' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import TabHeader from '../../base/tab-header' -import { checkOrSetAccessToken, removeAccessToken } from '../utils' +import { removeAccessToken } from '../utils' import MenuDropdown from './menu-dropdown' import RunBatch from './run-batch' import ResDownload from './run-batch/res-download' @@ -376,8 +376,8 @@ const TextGeneration: FC = ({ } const fetchInitData = async () => { - if (!isInstalledApp) - await checkOrSetAccessToken() + // if (!isInstalledApp) + // await checkOrSetAccessToken() return Promise.all([ isInstalledApp diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index adb926c7ca..1c1b6adfe8 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -18,8 +18,8 @@ import { import ThemeSwitcher from '@/app/components/base/theme-switcher' import type { SiteInfo } from '@/models/share' import cn from '@/utils/classnames' -import { useGlobalPublicStore } from '@/context/global-public-context' import { AccessMode } from '@/models/access-control' +import { useWebAppStore } from '@/context/web-app-context' type Props = { data?: SiteInfo @@ -32,7 +32,7 @@ const MenuDropdown: FC = ({ placement, hideLogout, }) => { - const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const router = useRouter() const pathname = usePathname() const { t } = useTranslation() diff --git a/web/app/components/share/utils.ts b/web/app/components/share/utils.ts index 8a897ab59a..0c6457fb0c 100644 --- a/web/app/components/share/utils.ts +++ b/web/app/components/share/utils.ts @@ -10,7 +10,7 @@ export const getInitialTokenV2 = (): Record => ({ version: 2, }) -export const checkOrSetAccessToken = async (appCode?: string) => { +export const checkOrSetAccessToken = async (appCode?: string | null) => { const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0] const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index 26ad84be65..324ac019c8 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -7,15 +7,12 @@ import type { SystemFeatures } from '@/types/feature' import { defaultSystemFeatures } from '@/types/feature' import { getSystemFeatures } from '@/service/common' import Loading from '@/app/components/base/loading' -import { AccessMode } from '@/models/access-control' type GlobalPublicStore = { isGlobalPending: boolean setIsGlobalPending: (isPending: boolean) => void systemFeatures: SystemFeatures setSystemFeatures: (systemFeatures: SystemFeatures) => void - webAppAccessMode: AccessMode, - setWebAppAccessMode: (webAppAccessMode: AccessMode) => void } export const useGlobalPublicStore = create(set => ({ @@ -23,8 +20,6 @@ export const useGlobalPublicStore = create(set => ({ setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })), systemFeatures: defaultSystemFeatures, setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })), - webAppAccessMode: AccessMode.PUBLIC, - setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })), })) const GlobalPublicStoreProvider: FC = ({ diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx new file mode 100644 index 0000000000..28a3312b0d --- /dev/null +++ b/web/context/web-app-context.tsx @@ -0,0 +1,74 @@ +'use client' + +import Loading from '@/app/components/base/loading' +import { AccessMode } from '@/models/access-control' +import { useAppAccessModeByCode } from '@/service/use-share' +import type { App } from '@/types/app' +import { usePathname, useSearchParams } from 'next/navigation' +import type { FC, PropsWithChildren } from 'react' +import { useEffect } from 'react' +import { useState } from 'react' +import { create } from 'zustand' + +type WebAppStore = { + shareCode: string | null + updateShareCode: (shareCode: string | null) => void + appInfo: App | null + updateAppInfo: (appInfo: App | null) => void + webAppAccessMode: AccessMode + updateWebAppAccessMode: (accessMode: AccessMode) => void +} + +export const useWebAppStore = create(set => ({ + shareCode: null, + updateShareCode: (shareCode: string | null) => set(() => ({ shareCode })), + appInfo: null, + updateAppInfo: (appInfo: App | null) => set(() => ({ appInfo })), + webAppAccessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + updateWebAppAccessMode: (accessMode: AccessMode) => set(() => ({ webAppAccessMode: accessMode })), +})) + +const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => { + if (!redirectUrl || redirectUrl.length === 0) + return null + const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) + return url.pathname.split('/').pop() || null +} +const getShareCodeFromPathname = (pathname: string): string | null => { + const code = pathname.split('/').pop() || null + if (code === 'webapp-signin') + return null + return code +} + +const WebAppStoreProvider: FC = ({ children }) => { + const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode) + const updateShareCode = useWebAppStore(state => state.updateShareCode) + const pathname = usePathname() + const searchParams = useSearchParams() + const redirectUrlParam = searchParams.get('redirect_url') + const [shareCode, setShareCode] = useState(null) + useEffect(() => { + const shareCodeFromRedirect = getShareCodeFromRedirectUrl(redirectUrlParam) + const shareCodeFromPathname = getShareCodeFromPathname(pathname) + const newShareCode = shareCodeFromRedirect || shareCodeFromPathname + setShareCode(newShareCode) + updateShareCode(newShareCode) + }, [pathname, redirectUrlParam, updateShareCode]) + const { isFetching, data: accessModeResult } = useAppAccessModeByCode(shareCode) + useEffect(() => { + if (accessModeResult?.accessMode) + updateWebAppAccessMode(accessModeResult.accessMode) + }, [accessModeResult, updateWebAppAccessMode]) + if (isFetching) { + return
+ +
+ } + return ( + <> + {children} + + ) +} +export default WebAppStoreProvider diff --git a/web/i18n/en-US/login.ts b/web/i18n/en-US/login.ts index 0beb631d24..d47eb7c079 100644 --- a/web/i18n/en-US/login.ts +++ b/web/i18n/en-US/login.ts @@ -105,6 +105,7 @@ const translation = { licenseInactive: 'License Inactive', licenseInactiveTip: 'The Dify Enterprise license for your workspace is inactive. Please contact your administrator to continue using Dify.', webapp: { + login: 'Login', noLoginMethod: 'Authentication method not configured for web app', noLoginMethodTip: 'Please contact the system admin to add an authentication method.', disabled: 'Webapp authentication is disabled. Please contact the system admin to enable it. You can try to use the app directly.', diff --git a/web/i18n/ja-JP/login.ts b/web/i18n/ja-JP/login.ts index 84ab9eecd0..b37700eba2 100644 --- a/web/i18n/ja-JP/login.ts +++ b/web/i18n/ja-JP/login.ts @@ -106,6 +106,7 @@ const translation = { licenseExpired: 'ライセンスの有効期限が切れています', licenseLostTip: 'Dify ライセンスサーバーへの接続に失敗しました。続けて Dify を使用するために管理者に連絡してください。', webapp: { + login: 'ログイン', noLoginMethod: 'Web アプリに対して認証方法が構成されていません', noLoginMethodTip: 'システム管理者に連絡して、認証方法を追加してください。', disabled: 'Web アプリの認証が無効になっています。システム管理者に連絡して有効にしてください。直接アプリを使用してみてください。', diff --git a/web/i18n/zh-Hans/login.ts b/web/i18n/zh-Hans/login.ts index a37fc104eb..b63630e288 100644 --- a/web/i18n/zh-Hans/login.ts +++ b/web/i18n/zh-Hans/login.ts @@ -106,6 +106,7 @@ const translation = { licenseInactive: '许可证未激活', licenseInactiveTip: '您所在空间的 Dify Enterprise 许可证尚未激活,请联系管理员以继续使用 Dify。', webapp: { + login: '登录', noLoginMethod: 'Web 应用未配置身份认证方式', noLoginMethodTip: '请联系系统管理员添加身份认证方式', disabled: 'Web 应用身份认证已禁用,请联系系统管理员启用。您也可以尝试直接使用应用。', diff --git a/web/service/base.ts b/web/service/base.ts index 49377be912..8ffacaa0f1 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -413,7 +413,7 @@ export const ssePost = async ( if (data.code === 'unauthorized') { removeAccessToken() - globalThis.location.reload() + requiredWebSSOLogin() } } }) @@ -507,7 +507,7 @@ export const request = async(url: string, options = {}, otherOptions?: IOther } = otherOptionsForBaseFetch if (isPublicAPI && code === 'unauthorized') { removeAccessToken() - globalThis.location.reload() + requiredWebSSOLogin() return Promise.reject(err) } if (code === 'init_validate_failed' && IS_CE_EDITION && !silent) { diff --git a/web/service/use-share.ts b/web/service/use-share.ts index b8f96f6cc5..46181c4878 100644 --- a/web/service/use-share.ts +++ b/web/service/use-share.ts @@ -1,14 +1,22 @@ +import { useGlobalPublicStore } from '@/context/global-public-context' +import { AccessMode } from '@/models/access-control' import { useQuery } from '@tanstack/react-query' import { getAppAccessModeByAppCode } from './share' const NAME_SPACE = 'webapp' export const useAppAccessModeByCode = (code: string | null) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) return useQuery({ queryKey: [NAME_SPACE, 'appAccessMode', code], queryFn: () => { - if (!code) - return null + if (systemFeatures.webapp_auth.enabled === false) { + return { + accessMode: AccessMode.PUBLIC, + } + } + if (!code || code.length === 0) + return Promise.reject(new Error('App code is required to get access mode')) return getAppAccessModeByAppCode(code) },