From 9915a70d7f169e3e66f5a18966909b240a6c3320 Mon Sep 17 00:00:00 2001 From: NFish Date: Thu, 5 Jun 2025 10:55:17 +0800 Subject: [PATCH] Fix/webapp access scope (#20109) --- web/app/(commonLayout)/apps/AppCard.tsx | 7 +- web/app/(shareLayout)/layout.tsx | 40 +++- .../webapp-reset-password/check-code/page.tsx | 96 +++++++++ .../webapp-reset-password/layout.tsx | 30 +++ .../webapp-reset-password/page.tsx | 104 ++++++++++ .../set-password/page.tsx | 188 ++++++++++++++++++ .../webapp-signin/check-code/page.tsx | 115 +++++++++++ .../components/external-member-sso-auth.tsx | 80 ++++++++ .../components/mail-and-code-auth.tsx | 68 +++++++ .../components/mail-and-password-auth.tsx | 171 ++++++++++++++++ .../webapp-signin/components/sso-auth.tsx | 88 ++++++++ .../(shareLayout)/webapp-signin/layout.tsx | 25 +++ .../webapp-signin/normalForm.tsx | 176 ++++++++++++++++ web/app/(shareLayout)/webapp-signin/page.tsx | 158 +++++++-------- .../app/app-access-control/index.tsx | 18 +- .../specific-groups-or-members.tsx | 12 -- .../components/app/app-publisher/index.tsx | 31 ++- web/app/components/app/overview/appCard.tsx | 32 ++- web/app/components/base/app-unavailable.tsx | 5 +- .../base/chat/chat-with-history/context.tsx | 3 - .../base/chat/chat-with-history/hooks.tsx | 11 +- .../base/chat/chat-with-history/index.tsx | 2 - .../chat/chat-with-history/sidebar/index.tsx | 4 +- .../base/chat/embedded-chatbot/context.tsx | 3 - .../base/chat/embedded-chatbot/hooks.tsx | 11 +- .../share/text-generation/menu-dropdown.tsx | 24 ++- web/app/components/share/utils.ts | 11 +- web/app/signin/LoginLogo.tsx | 8 +- web/context/global-public-context.tsx | 15 +- web/hooks/use-document-title.spec.ts | 6 +- web/hooks/use-document-title.ts | 2 +- web/i18n/en-US/app.ts | 14 +- web/i18n/en-US/share-app.ts | 3 + web/i18n/ja-JP/app.ts | 21 +- web/i18n/ja-JP/share-app.ts | 3 + web/i18n/zh-Hans/app.ts | 19 +- web/i18n/zh-Hans/share-app.ts | 3 + web/models/access-control.ts | 1 + web/service/base.ts | 1 + web/service/common.ts | 25 +++ web/service/share.ts | 39 +++- web/service/use-share.ts | 17 ++ 42 files changed, 1484 insertions(+), 206 deletions(-) create mode 100644 web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx create mode 100644 web/app/(shareLayout)/webapp-reset-password/layout.tsx create mode 100644 web/app/(shareLayout)/webapp-reset-password/page.tsx create mode 100644 web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/check-code/page.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/layout.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/normalForm.tsx create mode 100644 web/service/use-share.ts diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index 42967b96f..31b9ed87c 100644 --- a/web/app/(commonLayout)/apps/AppCard.tsx +++ b/web/app/(commonLayout)/apps/AppCard.tsx @@ -4,7 +4,7 @@ import { useContext, useContextSelector } from 'use-context-selector' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react' +import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import cn from '@/utils/classnames' import type { App } from '@/types/app' import Confirm from '@/app/components/base/confirm' @@ -338,7 +338,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{app.access_mode === AccessMode.PUBLIC && - + } {app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && @@ -346,6 +346,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { {app.access_mode === AccessMode.ORGANIZATION && } + {app.access_mode === AccessMode.EXTERNAL_MEMBERS && + + }
diff --git a/web/app/(shareLayout)/layout.tsx b/web/app/(shareLayout)/layout.tsx index 83adbd3ca..8db336a17 100644 --- a/web/app/(shareLayout)/layout.tsx +++ b/web/app/(shareLayout)/layout.tsx @@ -1,14 +1,42 @@ -import React from 'react' +'use client' +import React, { useEffect, useState } from 'react' import type { FC } from 'react' -import type { Metadata } from 'next' - -export const metadata: Metadata = { - icons: 'data:,', // prevent browser from using default favicon -} +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' const Layout: FC<{ children: React.ReactNode }> = ({ children }) => { + const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending) + const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode) + const pathname = usePathname() + const searchParams = useSearchParams() + const redirectUrl = searchParams.get('redirect_url') + const [isLoading, setIsLoading] = useState(true) + useEffect(() => { + (async () => { + let appCode: string | null = null + if (redirectUrl) + appCode = redirectUrl?.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]) + if (isLoading || isGlobalPending) { + return
+ +
+ } return (
{children} diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx new file mode 100644 index 000000000..da754794b --- /dev/null +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -0,0 +1,96 @@ +'use client' +import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Countdown from '@/app/components/signin/countdown' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common' +import I18NContext from '@/context/i18n' + +export default function CheckCode() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const email = decodeURIComponent(searchParams.get('email') as string) + const token = decodeURIComponent(searchParams.get('token') as string) + const [code, setVerifyCode] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const verify = async () => { + try { + if (!code.trim()) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.emptyCode'), + }) + return + } + if (!/\d{6}/.test(code)) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.invalidCode'), + }) + return + } + setIsLoading(true) + const ret = await verifyWebAppResetPasswordCode({ email, code, token }) + if (ret.is_valid) { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(ret.token)) + router.push(`/webapp-reset-password/set-password?${params.toString()}`) + } + } + catch (error) { console.error(error) } + finally { + setIsLoading(false) + } + } + + const resendCode = async () => { + try { + const res = await sendWebAppResetPasswordCode(email, locale) + if (res.result === 'success') { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(res.data)) + router.replace(`/webapp-reset-password/check-code?${params.toString()}`) + } + } + catch (error) { console.error(error) } + } + + return
+
+ +
+
+

{t('login.checkCode.checkYourEmail')}

+

+ +
+ {t('login.checkCode.validTime')} +

+
+ +
+ + + setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> + + + +
+
+
+
router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'> +
+ +
+ {t('login.back')} +
+
+} diff --git a/web/app/(shareLayout)/webapp-reset-password/layout.tsx b/web/app/(shareLayout)/webapp-reset-password/layout.tsx new file mode 100644 index 000000000..e0ac6b9ad --- /dev/null +++ b/web/app/(shareLayout)/webapp-reset-password/layout.tsx @@ -0,0 +1,30 @@ +'use client' +import Header from '@/app/signin/_header' + +import cn from '@/utils/classnames' +import { useGlobalPublicStore } from '@/context/global-public-context' + +export default function SignInLayout({ children }: any) { + const { systemFeatures } = useGlobalPublicStore() + return <> +
+
+
+
+
+ {children} +
+
+ {!systemFeatures.branding.enabled &&
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
} +
+
+ +} diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx new file mode 100644 index 000000000..96cd4c580 --- /dev/null +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -0,0 +1,104 @@ +'use client' +import Link from 'next/link' +import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import { emailRegex } from '@/config' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { sendResetPasswordCode } from '@/service/common' +import I18NContext from '@/context/i18n' +import { noop } from 'lodash-es' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function CheckCode() { + const { t } = useTranslation() + useDocumentTitle('') + const searchParams = useSearchParams() + const router = useRouter() + const [email, setEmail] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const handleGetEMailVerificationCode = async () => { + try { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + setIsLoading(true) + const res = await sendResetPasswordCode(email, locale) + if (res.result === 'success') { + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(res.data)) + params.set('email', encodeURIComponent(email)) + router.push(`/webapp-reset-password/check-code?${params.toString()}`) + } + else if (res.code === 'account_not_found') { + Toast.notify({ + type: 'error', + message: t('login.error.registrationNotAllowed'), + }) + } + else { + Toast.notify({ + type: 'error', + message: res.data, + }) + } + } + catch (error) { + console.error(error) + } + finally { + setIsLoading(false) + } + } + + return
+
+ +
+
+

{t('login.resetPassword')}

+

+ {t('login.resetPasswordDesc')} +

+
+ +
+ +
+ +
+ setEmail(e.target.value)} /> +
+
+ +
+
+
+
+
+
+ +
+ +
+ {t('login.backToLogin')} + +
+} diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx new file mode 100644 index 000000000..9f9a8ad4e --- /dev/null +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -0,0 +1,188 @@ +'use client' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import cn from 'classnames' +import { RiCheckboxCircleFill } from '@remixicon/react' +import { useCountDown } from 'ahooks' +import Button from '@/app/components/base/button' +import { changeWebAppPasswordWithToken } from '@/service/common' +import Toast from '@/app/components/base/toast' +import Input from '@/app/components/base/input' + +const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +const ChangePasswordForm = () => { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const token = decodeURIComponent(searchParams.get('token') || '') + + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showSuccess, setShowSuccess] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const showErrorMessage = useCallback((message: string) => { + Toast.notify({ + type: 'error', + message, + }) + }, []) + + const getSignInUrl = () => { + return `/webapp-signin?redirect_url=${searchParams.get('redirect_url') || ''}` + } + + const AUTO_REDIRECT_TIME = 5000 + const [leftTime, setLeftTime] = useState(undefined) + const [countdown] = useCountDown({ + leftTime, + onEnd: () => { + router.replace(getSignInUrl()) + }, + }) + + const valid = useCallback(() => { + if (!password.trim()) { + showErrorMessage(t('login.error.passwordEmpty')) + return false + } + if (!validPassword.test(password)) { + showErrorMessage(t('login.error.passwordInvalid')) + return false + } + if (password !== confirmPassword) { + showErrorMessage(t('common.account.notEqual')) + return false + } + return true + }, [password, confirmPassword, showErrorMessage, t]) + + const handleChangePassword = useCallback(async () => { + if (!valid()) + return + try { + await changeWebAppPasswordWithToken({ + url: '/forgot-password/resets', + body: { + token, + new_password: password, + password_confirm: confirmPassword, + }, + }) + setShowSuccess(true) + setLeftTime(AUTO_REDIRECT_TIME) + } + catch (error) { + console.error(error) + } + }, [password, token, valid, confirmPassword]) + + return ( +
+ {!showSuccess && ( +
+
+

+ {t('login.changePassword')} +

+

+ {t('login.changePasswordTip')} +

+
+ +
+
+ {/* Password */} +
+ +
+ setPassword(e.target.value)} + placeholder={t('login.passwordPlaceholder') || ''} + /> + +
+ +
+
+
{t('login.error.passwordInvalid')}
+
+ {/* Confirm Password */} +
+ +
+ setConfirmPassword(e.target.value)} + placeholder={t('login.confirmPasswordPlaceholder') || ''} + /> +
+ +
+
+
+
+ +
+
+
+
+ )} + {showSuccess && ( +
+
+
+ +
+

+ {t('login.passwordChangedTip')} +

+
+
+ +
+
+ )} +
+ ) +} + +export default ChangePasswordForm diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx new file mode 100644 index 000000000..1b8f18c98 --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -0,0 +1,115 @@ +'use client' +import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useCallback, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Countdown from '@/app/components/signin/countdown' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common' +import I18NContext from '@/context/i18n' +import { setAccessToken } from '@/app/components/share/utils' +import { fetchAccessToken } from '@/service/share' + +export default function CheckCode() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const email = decodeURIComponent(searchParams.get('email') as string) + const token = decodeURIComponent(searchParams.get('token') as string) + const [code, setVerifyCode] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + const redirectUrl = searchParams.get('redirect_url') + + const getAppCodeFromRedirectUrl = useCallback(() => { + const appCode = redirectUrl?.split('/').pop() + if (!appCode) + return null + + return appCode + }, [redirectUrl]) + + const verify = async () => { + try { + const appCode = getAppCodeFromRedirectUrl() + if (!code.trim()) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.emptyCode'), + }) + return + } + if (!/\d{6}/.test(code)) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.invalidCode'), + }) + return + } + if (!redirectUrl || !appCode) { + Toast.notify({ + type: 'error', + message: t('login.error.redirectUrlMissing'), + }) + return + } + setIsLoading(true) + const ret = await webAppEmailLoginWithCode({ email, code, token }) + if (ret.result === 'success') { + localStorage.setItem('webapp_access_token', ret.data.access_token) + const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: ret.data.access_token }) + await setAccessToken(appCode, tokenResp.access_token) + router.replace(redirectUrl) + } + } + catch (error) { console.error(error) } + finally { + setIsLoading(false) + } + } + + const resendCode = async () => { + try { + const ret = await sendWebAppEMailLoginCode(email, locale) + if (ret.result === 'success') { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(ret.data)) + router.replace(`/webapp-signin/check-code?${params.toString()}`) + } + } + catch (error) { console.error(error) } + } + + return
+
+ +
+
+

{t('login.checkCode.checkYourEmail')}

+

+ +
+ {t('login.checkCode.validTime')} +

+
+ +
+ + setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> + + + +
+
+
+
router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'> +
+ +
+ {t('login.back')} +
+
+} diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx new file mode 100644 index 000000000..e9b15ae33 --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -0,0 +1,80 @@ +'use client' +import { useRouter, useSearchParams } from 'next/navigation' +import React, { useCallback, useEffect } from 'react' +import Toast from '@/app/components/base/toast' +import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { SSOProtocol } from '@/types/feature' +import Loading from '@/app/components/base/loading' +import AppUnavailable from '@/app/components/base/app-unavailable' + +const ExternalMemberSSOAuth = () => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const searchParams = useSearchParams() + const router = useRouter() + + const redirectUrl = searchParams.get('redirect_url') + + const showErrorToast = (message: string) => { + Toast.notify({ + type: 'error', + message, + }) + } + + const getAppCodeFromRedirectUrl = useCallback(() => { + const appCode = redirectUrl?.split('/').pop() + if (!appCode) + return null + + return appCode + }, [redirectUrl]) + + const handleSSOLogin = useCallback(async () => { + const appCode = getAppCodeFromRedirectUrl() + if (!appCode || !redirectUrl) { + showErrorToast('redirect url or app code is invalid.') + return + } + + switch (systemFeatures.webapp_auth.sso_config.protocol) { + case SSOProtocol.SAML: { + const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) + router.push(samlRes.url) + break + } + case SSOProtocol.OIDC: { + const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) + router.push(oidcRes.url) + break + } + case SSOProtocol.OAuth2: { + const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) + router.push(oauth2Res.url) + break + } + case '': + break + default: + showErrorToast('SSO protocol is not supported.') + } + }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol]) + + useEffect(() => { + handleSSOLogin() + }, [handleSSOLogin]) + + if (!systemFeatures.webapp_auth.sso_config.protocol) { + return
+ +
+ } + + return ( +
+ +
+ ) +} + +export default React.memo(ExternalMemberSSOAuth) diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx new file mode 100644 index 000000000..29af3e3a5 --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Input from '@/app/components/base/input' +import Button from '@/app/components/base/button' +import { emailRegex } from '@/config' +import Toast from '@/app/components/base/toast' +import { sendWebAppEMailLoginCode } from '@/service/common' +import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import I18NContext from '@/context/i18n' +import { noop } from 'lodash-es' + +export default function MailAndCodeAuth() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const emailFromLink = decodeURIComponent(searchParams.get('email') || '') + const [email, setEmail] = useState(emailFromLink) + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const handleGetEMailVerificationCode = async () => { + try { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + setIsLoading(true) + const ret = await sendWebAppEMailLoginCode(email, locale) + if (ret.result === 'success') { + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + const params = new URLSearchParams(searchParams) + params.set('email', encodeURIComponent(email)) + params.set('token', encodeURIComponent(ret.data)) + router.push(`/webapp-signin/check-code?${params.toString()}`) + } + } + catch (error) { + console.error(error) + } + finally { + setIsLoading(false) + } + } + + return (
+ +
+ +
+ setEmail(e.target.value)} /> +
+
+ +
+
+
+ ) +} 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 new file mode 100644 index 000000000..d9e56af1b --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -0,0 +1,171 @@ +import Link from 'next/link' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Button from '@/app/components/base/button' +import Toast from '@/app/components/base/toast' +import { emailRegex } from '@/config' +import { webAppLogin } from '@/service/common' +import Input from '@/app/components/base/input' +import I18NContext from '@/context/i18n' +import { noop } from 'lodash-es' +import { setAccessToken } from '@/app/components/share/utils' +import { fetchAccessToken } from '@/service/share' + +type MailAndPasswordAuthProps = { + isEmailSetup: boolean +} + +const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) { + const { t } = useTranslation() + const { locale } = useContext(I18NContext) + const router = useRouter() + const searchParams = useSearchParams() + const [showPassword, setShowPassword] = useState(false) + const emailFromLink = decodeURIComponent(searchParams.get('email') || '') + const [email, setEmail] = useState(emailFromLink) + const [password, setPassword] = useState('') + + const [isLoading, setIsLoading] = useState(false) + const redirectUrl = searchParams.get('redirect_url') + + const getAppCodeFromRedirectUrl = useCallback(() => { + const appCode = redirectUrl?.split('/').pop() + if (!appCode) + return null + + return appCode + }, [redirectUrl]) + const handleEmailPasswordLogin = async () => { + const appCode = getAppCodeFromRedirectUrl() + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + if (!password?.trim()) { + Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') }) + return + } + if (!passwordRegex.test(password)) { + Toast.notify({ + type: 'error', + message: t('login.error.passwordInvalid'), + }) + return + } + if (!redirectUrl || !appCode) { + Toast.notify({ + type: 'error', + message: t('login.error.redirectUrlMissing'), + }) + return + } + try { + setIsLoading(true) + const loginData: Record = { + email, + password, + language: locale, + remember_me: true, + } + + const res = await webAppLogin({ + url: '/login', + body: loginData, + }) + if (res.result === 'success') { + localStorage.setItem('webapp_access_token', res.data.access_token) + const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: res.data.access_token }) + await setAccessToken(appCode, tokenResp.access_token) + router.replace(redirectUrl) + } + else { + Toast.notify({ + type: 'error', + message: res.data, + }) + } + } + + finally { + setIsLoading(false) + } + } + + return
+
+ +
+ setEmail(e.target.value)} + id="email" + type="email" + autoComplete="email" + placeholder={t('login.emailPlaceholder') || ''} + tabIndex={1} + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') + handleEmailPasswordLogin() + }} + type={showPassword ? 'text' : 'password'} + autoComplete="current-password" + placeholder={t('login.passwordPlaceholder') || ''} + tabIndex={2} + /> +
+ +
+
+
+ +
+ +
+
+} diff --git a/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx new file mode 100644 index 000000000..5d649322b --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx @@ -0,0 +1,88 @@ +'use client' +import { useRouter, useSearchParams } from 'next/navigation' +import type { FC } from 'react' +import { useCallback } from 'react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' +import Toast from '@/app/components/base/toast' +import Button from '@/app/components/base/button' +import { SSOProtocol } from '@/types/feature' +import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share' + +type SSOAuthProps = { + protocol: SSOProtocol | '' +} + +const SSOAuth: FC = ({ + protocol, +}) => { + const router = useRouter() + const { t } = useTranslation() + const searchParams = useSearchParams() + + const redirectUrl = searchParams.get('redirect_url') + const getAppCodeFromRedirectUrl = useCallback(() => { + const appCode = redirectUrl?.split('/').pop() + if (!appCode) + return null + + return appCode + }, [redirectUrl]) + + const [isLoading, setIsLoading] = useState(false) + + const handleSSOLogin = () => { + const appCode = getAppCodeFromRedirectUrl() + if (!redirectUrl || !appCode) { + Toast.notify({ + type: 'error', + message: 'invalid redirect URL or app code', + }) + return + } + setIsLoading(true) + if (protocol === SSOProtocol.SAML) { + fetchMembersSAMLSSOUrl(appCode, redirectUrl).then((res) => { + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else if (protocol === SSOProtocol.OIDC) { + fetchMembersOIDCSSOUrl(appCode, redirectUrl).then((res) => { + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else if (protocol === SSOProtocol.OAuth2) { + fetchMembersOAuth2SSOUrl(appCode, redirectUrl).then((res) => { + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else { + Toast.notify({ + type: 'error', + message: 'invalid SSO protocol', + }) + setIsLoading(false) + } + } + + return ( + + ) +} + +export default SSOAuth diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx new file mode 100644 index 000000000..a03364d32 --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -0,0 +1,25 @@ +'use client' + +import cn from '@/utils/classnames' +import { useGlobalPublicStore } from '@/context/global-public-context' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function SignInLayout({ children }: any) { + const { systemFeatures } = useGlobalPublicStore() + useDocumentTitle('') + return <> +
+
+ {/*
*/} +
+
+ {children} +
+
+ {systemFeatures.branding.enabled === false &&
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
} +
+
+ +} diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx new file mode 100644 index 000000000..d6bdf607b --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Link from 'next/link' +import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' +import Loading from '@/app/components/base/loading' +import MailAndCodeAuth from './components/mail-and-code-auth' +import MailAndPasswordAuth from './components/mail-and-password-auth' +import SSOAuth from './components/sso-auth' +import cn from '@/utils/classnames' +import { LicenseStatus } from '@/types/feature' +import { IS_CE_EDITION } from '@/config' +import { useGlobalPublicStore } from '@/context/global-public-context' + +const NormalForm = () => { + const { t } = useTranslation() + + const [isLoading, setIsLoading] = useState(true) + const { systemFeatures } = useGlobalPublicStore() + const [authType, updateAuthType] = useState<'code' | 'password'>('password') + const [showORLine, setShowORLine] = useState(false) + const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false) + + const init = useCallback(async () => { + try { + setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin) + setShowORLine((systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login)) + updateAuthType(systemFeatures.enable_email_password_login ? 'password' : 'code') + } + catch (error) { + console.error(error) + setAllMethodsAreDisabled(true) + } + finally { setIsLoading(false) } + }, [systemFeatures]) + useEffect(() => { + init() + }, [init]) + if (isLoading) { + return
+ +
+ } + if (systemFeatures.license?.status === LicenseStatus.LOST) { + return
+
+
+
+ + +
+

{t('login.licenseLost')}

+

{t('login.licenseLostTip')}

+
+
+
+ } + if (systemFeatures.license?.status === LicenseStatus.EXPIRED) { + return
+
+
+
+ + +
+

{t('login.licenseExpired')}

+

{t('login.licenseExpiredTip')}

+
+
+
+ } + if (systemFeatures.license?.status === LicenseStatus.INACTIVE) { + return
+
+
+
+ + +
+

{t('login.licenseInactive')}

+

{t('login.licenseInactiveTip')}

+
+
+
+ } + + return ( + <> +
+
+

{t('login.pageTitle')}

+ {!systemFeatures.branding.enabled &&

{t('login.welcome')}

} +
+
+
+ {systemFeatures.sso_enforced_for_signin &&
+ +
} +
+ + {showORLine &&
+ +
+ {t('login.or')} +
+
} + { + (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <> + {systemFeatures.enable_email_code_login && authType === 'code' && <> + + {systemFeatures.enable_email_password_login &&
{ updateAuthType('password') }}> + {t('login.usePassword')} +
} + } + {systemFeatures.enable_email_password_login && authType === 'password' && <> + + {systemFeatures.enable_email_code_login &&
{ updateAuthType('code') }}> + {t('login.useVerificationCode')} +
} + } + + } + {allMethodsAreDisabled && <> +
+
+ +
+

{t('login.noLoginMethod')}

+

{t('login.noLoginMethodTip')}

+
+
+ +
+ } + {!systemFeatures.branding.enabled && <> +
+ {t('login.tosDesc')} +   + {t('login.tos')} +  &  + {t('login.pp')} +
+ {IS_CE_EDITION &&
+ {t('login.goToInit')} +   + {t('login.setAdminAccount')} +
} + } + +
+
+ + ) +} + +export default NormalForm diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 668c3f312..c12fde38d 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -3,19 +3,20 @@ import { useRouter, useSearchParams } from 'next/navigation' import type { FC } from 'react' import React, { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { RiDoorLockLine } from '@remixicon/react' -import cn from '@/utils/classnames' import Toast from '@/app/components/base/toast' -import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' -import { setAccessToken } from '@/app/components/share/utils' +import { removeAccessToken, setAccessToken } from '@/app/components/share/utils' import { useGlobalPublicStore } from '@/context/global-public-context' -import { SSOProtocol } from '@/types/feature' 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' const WebSSOForm: FC = () => { const { t } = useTranslation() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) const searchParams = useSearchParams() const router = useRouter() @@ -23,10 +24,22 @@ const WebSSOForm: FC = () => { const tokenFromUrl = searchParams.get('web_sso_token') const message = searchParams.get('message') - const showErrorToast = (message: string) => { + const getSigninUrl = useCallback(() => { + const params = new URLSearchParams(searchParams) + params.delete('message') + return `/webapp-signin?${params.toString()}` + }, [searchParams]) + + const backToHome = useCallback(() => { + removeAccessToken() + const url = getSigninUrl() + router.replace(url) + }, [getSigninUrl, router]) + + const showErrorToast = (msg: string) => { Toast.notify({ type: 'error', - message, + message: msg, }) } @@ -38,102 +51,73 @@ const WebSSOForm: FC = () => { return appCode }, [redirectUrl]) - const processTokenAndRedirect = useCallback(async () => { - const appCode = getAppCodeFromRedirectUrl() - if (!appCode || !tokenFromUrl || !redirectUrl) { - showErrorToast('redirect url or app code or token is invalid.') - return - } + useEffect(() => { + (async () => { + if (message) + return - await setAccessToken(appCode, tokenFromUrl) - router.push(redirectUrl) - }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl]) - - const handleSSOLogin = useCallback(async () => { - const appCode = getAppCodeFromRedirectUrl() - if (!appCode || !redirectUrl) { - showErrorToast('redirect url or app code is invalid.') - return - } - - switch (systemFeatures.webapp_auth.sso_config.protocol) { - case SSOProtocol.SAML: { - const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) - router.push(samlRes.url) - break + 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(redirectUrl) + return } - case SSOProtocol.OIDC: { - const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) - router.push(oidcRes.url) - break + 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(redirectUrl) } - case SSOProtocol.OAuth2: { - const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) - router.push(oauth2Res.url) - break - } - case '': - break - default: - showErrorToast('SSO protocol is not supported.') - } - }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol]) + })() + }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message]) useEffect(() => { - const init = async () => { - if (message) { - showErrorToast(message) - return - } + if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl) + router.replace(redirectUrl) + }, [webAppAccessMode, router, redirectUrl]) - if (!tokenFromUrl) { - await handleSSOLogin() - return - } - - await processTokenAndRedirect() - } - - init() - }, [message, processTokenAndRedirect, tokenFromUrl, handleSSOLogin]) - if (tokenFromUrl) - return
- if (message) { + if (tokenFromUrl) { return
- +
} - if (systemFeatures.webapp_auth.enabled) { - if (systemFeatures.webapp_auth.allow_sso) { - return ( -
-
- -
-
- ) - } - return
-
-
- -
-

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

-

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

-
-
- -
+ if (message) { + return
+ + {t('share.login.backToHome')}
} - else { + if (!redirectUrl) { + showErrorToast('redirect url is invalid.') + return
+ +
+ } + if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) { + return
+ +
+ } + if (!systemFeatures.webapp_auth.enabled) { return

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

} + if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) { + return
+ +
+ } + + if (webAppAccessMode && webAppAccessMode === AccessMode.EXTERNAL_MEMBERS) + return + + return
+ + {t('share.login.backToHome')} +
} export default React.memo(WebSSOForm) diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 2f15c8ec4..13faaea95 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { Dialog } from '@headlessui/react' -import { RiBuildingLine, RiGlobalLine } from '@remixicon/react' +import { Description as DialogDescription, DialogTitle } from '@headlessui/react' +import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useCallback, useEffect } from 'react' import Button from '../../base/button' @@ -67,8 +67,8 @@ export default function AccessControl(props: AccessControlProps) { return
- {t('app.accessControlDialog.title')} - {t('app.accessControlDialog.description')} + {t('app.accessControlDialog.title')} + {t('app.accessControlDialog.description')}
@@ -80,12 +80,20 @@ export default function AccessControl(props: AccessControlProps) {

{t('app.accessControlDialog.accessItems.organization')}

- {!hideTip && }
+ +
+
+ +

{t('app.accessControlDialog.accessItems.external')}

+
+ {!hideTip && } +
+
diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx index f4872f8c9..b30c8f1ba 100644 --- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -3,12 +3,10 @@ import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from import { useTranslation } from 'react-i18next' import { useCallback, useEffect } from 'react' import Avatar from '../../base/avatar' -import Divider from '../../base/divider' import Tooltip from '../../base/tooltip' import Loading from '../../base/loading' import useAccessControlStore from '../../../../context/access-control-store' import AddMemberOrGroupDialog from './add-member-or-group-pop' -import { useGlobalPublicStore } from '@/context/global-public-context' import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects } from '@/service/access-control' @@ -19,11 +17,6 @@ export default function SpecificGroupsOrMembers() { const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const hideTip = systemFeatures.webapp_auth.enabled - && (systemFeatures.webapp_auth.allow_sso - || systemFeatures.webapp_auth.allow_email_password_login - || systemFeatures.webapp_auth.allow_email_code_login) const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) useEffect(() => { @@ -37,7 +30,6 @@ export default function SpecificGroupsOrMembers() {

{t('app.accessControlDialog.accessItems.specific')}

- {!hideTip && }
} @@ -48,10 +40,6 @@ export default function SpecificGroupsOrMembers() {

{t('app.accessControlDialog.accessItems.specific')}

- {!hideTip && <> - - - }
diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 8d0028c7d..5825bb72e 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -9,11 +9,14 @@ import dayjs from 'dayjs' import { RiArrowDownSLine, RiArrowRightSLine, + RiBuildingLine, + RiGlobalLine, RiLockLine, RiPlanetLine, RiPlayCircleLine, RiPlayList2Line, RiTerminalBoxLine, + RiVerifiedBadgeLine, } from '@remixicon/react' import { useKeyPress } from 'ahooks' import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' @@ -276,10 +279,30 @@ const AppPublisher = ({ setShowAppAccessControl(true) }}>
- - {appDetail?.access_mode === AccessMode.ORGANIZATION &&

{t('app.accessControlDialog.accessItems.organization')}

} - {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS &&

{t('app.accessControlDialog.accessItems.specific')}

} - {appDetail?.access_mode === AccessMode.PUBLIC &&

{t('app.accessControlDialog.accessItems.anyone')}

} + {appDetail?.access_mode === AccessMode.ORGANIZATION + && <> + +

{t('app.accessControlDialog.accessItems.organization')}

+ + } + {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS + && <> + +

{t('app.accessControlDialog.accessItems.specific')}

+ + } + {appDetail?.access_mode === AccessMode.PUBLIC + && <> + +

{t('app.accessControlDialog.accessItems.anyone')}

+ + } + {appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS + && <> + +

{t('app.accessControlDialog.accessItems.external')}

+ + }
{!isAppAccessSet &&

{t('app.publishApp.notSet')}

}
diff --git a/web/app/components/app/overview/appCard.tsx b/web/app/components/app/overview/appCard.tsx index 9b283cdf5..9f3b3ac4a 100644 --- a/web/app/components/app/overview/appCard.tsx +++ b/web/app/components/app/overview/appCard.tsx @@ -5,10 +5,13 @@ import { useTranslation } from 'react-i18next' import { RiArrowRightSLine, RiBookOpenLine, + RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, + RiGlobalLine, RiLockLine, RiPaintBrushLine, + RiVerifiedBadgeLine, RiWindowLine, } from '@remixicon/react' import SettingsModal from './settings' @@ -248,11 +251,30 @@ function AppCard({
- - {appDetail?.access_mode === AccessMode.ORGANIZATION &&

{t('app.accessControlDialog.accessItems.organization')}

} - {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS &&

{t('app.accessControlDialog.accessItems.specific')}

} - {appDetail?.access_mode === AccessMode.PUBLIC &&

{t('app.accessControlDialog.accessItems.anyone')}

} -
+ {appDetail?.access_mode === AccessMode.ORGANIZATION + && <> + +

{t('app.accessControlDialog.accessItems.organization')}

+ + } + {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS + && <> + +

{t('app.accessControlDialog.accessItems.specific')}

+ + } + {appDetail?.access_mode === AccessMode.PUBLIC + && <> + +

{t('app.accessControlDialog.accessItems.anyone')}

+ + } + {appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS + && <> + +

{t('app.accessControlDialog.accessItems.external')}

+ + }
{!isAppAccessSet &&

{t('app.publishApp.notSet')}

}
diff --git a/web/app/components/base/app-unavailable.tsx b/web/app/components/base/app-unavailable.tsx index 4e835cbfc..928c85026 100644 --- a/web/app/components/base/app-unavailable.tsx +++ b/web/app/components/base/app-unavailable.tsx @@ -1,4 +1,5 @@ 'use client' +import classNames from '@/utils/classnames' import type { FC } from 'react' import React from 'react' import { useTranslation } from 'react-i18next' @@ -7,17 +8,19 @@ type IAppUnavailableProps = { code?: number | string isUnknownReason?: boolean unknownReason?: string + className?: string } const AppUnavailable: FC = ({ code = 404, isUnknownReason, unknownReason, + className, }) => { const { t } = useTranslation() return ( -
+

({ - accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, userCanAccess: false, currentConversationId: '', appPrevChatTree: [], diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index dd7cb14b2..32f74e645 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -43,9 +43,8 @@ import { useAppFavicon } from '@/hooks/use-app-favicon' import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { noop } from 'lodash-es' -import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' -import { AccessMode } from '@/models/access-control' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] @@ -77,11 +76,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) - const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ - appId: installedAppInfo?.app.id || appInfo?.app_id, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId: installedAppInfo?.app.id || appInfo?.app_id, isInstalledApp, @@ -492,8 +486,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { return { appInfoError, - appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)), - accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC, + appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission), userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, isInstalledApp, appId, diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx index de023e7f5..1fd138319 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -124,7 +124,6 @@ const ChatWithHistoryWrap: FC = ({ const { appInfoError, appInfoLoading, - accessMode, userCanAccess, appData, appParams, @@ -169,7 +168,6 @@ const ChatWithHistoryWrap: FC = ({ appInfoError, appInfoLoading, appData, - accessMode, userCanAccess, appParams, appMeta, diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index fd317ccf9..4e50c1cb7 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -19,7 +19,6 @@ import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/re import DifyLogo from '@/app/components/base/logo/dify-logo' import type { ConversationItem } from '@/models/share' import cn from '@/utils/classnames' -import { AccessMode } from '@/models/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' type Props = { @@ -30,7 +29,6 @@ const Sidebar = ({ isPanel }: Props) => { const { t } = useTranslation() const { isInstalledApp, - accessMode, appData, handleNewConversation, pinnedConversationList, @@ -140,7 +138,7 @@ const Sidebar = ({ isPanel }: Props) => { )}

- + {/* powered by */}
{!appData?.custom_config?.remove_webapp_brand && ( diff --git a/web/app/components/base/chat/embedded-chatbot/context.tsx b/web/app/components/base/chat/embedded-chatbot/context.tsx index 5964efd80..d24265ed9 100644 --- a/web/app/components/base/chat/embedded-chatbot/context.tsx +++ b/web/app/components/base/chat/embedded-chatbot/context.tsx @@ -15,10 +15,8 @@ import type { ConversationItem, } from '@/models/share' import { noop } from 'lodash-es' -import { AccessMode } from '@/models/access-control' export type EmbeddedChatbotContextValue = { - accessMode?: AccessMode userCanAccess?: boolean appInfoError?: any appInfoLoading?: boolean @@ -58,7 +56,6 @@ export type EmbeddedChatbotContextValue = { export const EmbeddedChatbotContext = createContext({ userCanAccess: false, - accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, currentConversationId: '', appPrevChatList: [], pinnedConversationList: [], diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 40c56eca7..0158e8d04 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -36,9 +36,8 @@ import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { noop } from 'lodash-es' -import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' -import { AccessMode } from '@/models/access-control' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] @@ -70,11 +69,6 @@ export const useEmbeddedChatbot = () => { const isInstalledApp = false const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo) - const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ - appId: appInfo?.app_id, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp, @@ -385,8 +379,7 @@ export const useEmbeddedChatbot = () => { return { appInfoError, - appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)), - accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC, + appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission), userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, isInstalledApp, allowResetChat, diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index 19b660b08..adb926c7c 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -6,9 +6,8 @@ import type { Placement } from '@floating-ui/react' import { RiEqualizer2Line, } from '@remixicon/react' -import { useRouter } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import Divider from '../../base/divider' -import { removeAccessToken } from '../utils' import InfoModal from './info-modal' import ActionButton from '@/app/components/base/action-button' import { @@ -19,6 +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' type Props = { data?: SiteInfo @@ -31,7 +32,9 @@ const MenuDropdown: FC = ({ placement, hideLogout, }) => { + const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) const router = useRouter() + const pathname = usePathname() const { t } = useTranslation() const [open, doSetOpen] = useState(false) const openRef = useRef(open) @@ -45,9 +48,10 @@ const MenuDropdown: FC = ({ }, [setOpen]) const handleLogout = useCallback(() => { - removeAccessToken() - router.replace(`/webapp-signin?redirect_url=${window.location.href}`) - }, [router]) + localStorage.removeItem('token') + localStorage.removeItem('webapp_access_token') + router.replace(`/webapp-signin?redirect_url=${pathname}`) + }, [router, pathname]) const [show, setShow] = useState(false) @@ -92,6 +96,16 @@ const MenuDropdown: FC = ({ className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' >{t('common.userProfile.about')}
+ {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( +
+
+ {t('common.userProfile.logout')} +
+
+ )}
diff --git a/web/app/components/share/utils.ts b/web/app/components/share/utils.ts index 9ce891a50..d793d48b4 100644 --- a/web/app/components/share/utils.ts +++ b/web/app/components/share/utils.ts @@ -10,8 +10,8 @@ export const getInitialTokenV2 = (): Record => ({ version: 2, }) -export const checkOrSetAccessToken = async () => { - const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0] +export const checkOrSetAccessToken = async (appCode?: string) => { + const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0] const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) let accessTokenJson = getInitialTokenV2() @@ -23,8 +23,10 @@ export const checkOrSetAccessToken = async () => { catch { } + if (!accessTokenJson[sharedToken]?.[userId || 'DEFAULT']) { - const res = await fetchAccessToken(sharedToken, userId) + const webAppAccessToken = localStorage.getItem('webapp_access_token') + const res = await fetchAccessToken({ appCode: sharedToken, userId, webAppAccessToken }) accessTokenJson[sharedToken] = { ...accessTokenJson[sharedToken], [userId || 'DEFAULT']: res.access_token, @@ -33,7 +35,7 @@ export const checkOrSetAccessToken = async () => { } } -export const setAccessToken = async (sharedToken: string, token: string, user_id?: string) => { +export const setAccessToken = (sharedToken: string, token: string, user_id?: string) => { const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) let accessTokenJson = getInitialTokenV2() try { @@ -69,6 +71,7 @@ export const removeAccessToken = () => { } localStorage.removeItem(CONVERSATION_ID_INFO) + localStorage.removeItem('webapp_access_token') delete accessTokenJson[sharedToken] localStorage.setItem('token', JSON.stringify(accessTokenJson)) diff --git a/web/app/signin/LoginLogo.tsx b/web/app/signin/LoginLogo.tsx index 0753d1f98..73dfb8820 100644 --- a/web/app/signin/LoginLogo.tsx +++ b/web/app/signin/LoginLogo.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' import classNames from '@/utils/classnames' -import { useSelector } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' +import { useTheme } from 'next-themes' type LoginLogoProps = { className?: string @@ -12,11 +12,7 @@ const LoginLogo: FC = ({ className, }) => { const { systemFeatures } = useGlobalPublicStore() - const { theme } = useSelector((s) => { - return { - theme: s.theme, - } - }) + const { theme } = useTheme() let src = theme === 'light' ? '/logo/logo-site.png' : `/logo/logo-site-${theme}.png` if (systemFeatures.branding.enabled) diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index 5aa5e7a30..26ad84be6 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -7,19 +7,24 @@ 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 = { - isPending: boolean - setIsPending: (isPending: boolean) => void + isGlobalPending: boolean + setIsGlobalPending: (isPending: boolean) => void systemFeatures: SystemFeatures setSystemFeatures: (systemFeatures: SystemFeatures) => void + webAppAccessMode: AccessMode, + setWebAppAccessMode: (webAppAccessMode: AccessMode) => void } export const useGlobalPublicStore = create(set => ({ - isPending: true, - setIsPending: (isPending: boolean) => set(() => ({ isPending })), + isGlobalPending: true, + setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })), systemFeatures: defaultSystemFeatures, setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })), + webAppAccessMode: AccessMode.PUBLIC, + setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })), })) const GlobalPublicStoreProvider: FC = ({ @@ -29,7 +34,7 @@ const GlobalPublicStoreProvider: FC = ({ queryKey: ['systemFeatures'], queryFn: getSystemFeatures, }) - const { setSystemFeatures, setIsPending } = useGlobalPublicStore() + const { setSystemFeatures, setIsGlobalPending: setIsPending } = useGlobalPublicStore() useEffect(() => { if (data) setSystemFeatures({ ...defaultSystemFeatures, ...data }) diff --git a/web/hooks/use-document-title.spec.ts b/web/hooks/use-document-title.spec.ts index 88239ffbd..a8d3d56cf 100644 --- a/web/hooks/use-document-title.spec.ts +++ b/web/hooks/use-document-title.spec.ts @@ -11,7 +11,7 @@ describe('title should be empty if systemFeatures is pending', () => { act(() => { useGlobalPublicStore.setState({ systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } }, - isPending: true, + isGlobalPending: true, }) }) it('document title should be empty if set title', () => { @@ -28,7 +28,7 @@ describe('use default branding', () => { beforeEach(() => { act(() => { useGlobalPublicStore.setState({ - isPending: false, + isGlobalPending: false, systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } }, }) }) @@ -48,7 +48,7 @@ describe('use specific branding', () => { beforeEach(() => { act(() => { useGlobalPublicStore.setState({ - isPending: false, + isGlobalPending: false, systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } }, }) }) diff --git a/web/hooks/use-document-title.ts b/web/hooks/use-document-title.ts index 10275a196..2c848a1f5 100644 --- a/web/hooks/use-document-title.ts +++ b/web/hooks/use-document-title.ts @@ -3,7 +3,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { useFavicon, useTitle } from 'ahooks' export default function useDocumentTitle(title: string) { - const isPending = useGlobalPublicStore(s => s.isPending) + const isPending = useGlobalPublicStore(s => s.isGlobalPending) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const prefix = title ? `${title} - ` : '' let titleStr = '' diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 20a80ba4c..ccfe23ead 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -197,9 +197,10 @@ const translation = { }, accessControl: 'Web App Access Control', accessItemsDescription: { - anyone: 'Anyone can access the web app', - specific: 'Only specific groups or members can access the web app', - organization: 'Anyone in the organization can access the web app', + anyone: 'Anyone can access the web app (no login required)', + specific: 'Only specific members within the platform can access the Web application', + organization: 'All members within the platform can access the Web application', + external: 'Only authenticated external users can access the Web application', }, accessControlDialog: { title: 'Web App Access Control', @@ -207,15 +208,16 @@ const translation = { accessLabel: 'Who has access', accessItems: { anyone: 'Anyone with the link', - specific: 'Specific groups or members', - organization: 'Only members within the enterprise', + specific: 'Specific members within the platform', + organization: 'All members within the platform', + external: 'Authenticated external users', }, groups_one: '{{count}} GROUP', groups_other: '{{count}} GROUPS', members_one: '{{count}} MEMBER', members_other: '{{count}} MEMBERS', noGroupsOrMembers: 'No groups or members selected', - webAppSSONotEnabledTip: 'Please contact enterprise administrator to configure the web app authentication method.', + webAppSSONotEnabledTip: 'Please contact your organization administrator to configure external authentication for the Web application.', operateGroupAndMember: { searchPlaceholder: 'Search groups and members', allMembers: 'All members', diff --git a/web/i18n/en-US/share-app.ts b/web/i18n/en-US/share-app.ts index bf99005d7..ab589ffb7 100644 --- a/web/i18n/en-US/share-app.ts +++ b/web/i18n/en-US/share-app.ts @@ -77,6 +77,9 @@ const translation = { atLeastOne: 'Please input at least one row in the uploaded file.', }, }, + login: { + backToHome: 'Back to Home', + }, } export default translation diff --git a/web/i18n/ja-JP/app.ts b/web/i18n/ja-JP/app.ts index b4fc8d4d8..b501bc129 100644 --- a/web/i18n/ja-JP/app.ts +++ b/web/i18n/ja-JP/app.ts @@ -210,30 +210,27 @@ const translation = { }, accessControl: 'Web アプリアクセス制御', accessItemsDescription: { - anyone: '誰でも Web アプリにアクセス可能', - specific: '特定のグループまたはメンバーのみが Web アプリにアクセス可能', - organization: '組織内の誰でも Web アプリにアクセス可能', + anyone: '誰でもこの web アプリにアクセスできます(ログイン不要)', + specific: '特定のプラットフォーム内メンバーのみがこの Web アプリにアクセスできます', + organization: 'プラットフォーム内の全メンバーがこの Web アプリにアクセスできます', + external: '認証済みの外部ユーザーのみがこの Web アプリにアクセスできます', }, accessControlDialog: { title: 'アクセス権限', description: 'Web アプリのアクセス権限を設定します', accessLabel: '誰がアクセスできますか', - accessItemsDescription: { - anyone: '誰でも Web アプリにアクセス可能です', - specific: '特定のグループやメンバーが Web アプリにアクセス可能です', - organization: '組織内の誰でも Web アプリにアクセス可能です', - }, accessItems: { - anyone: 'すべてのユーザー', - specific: '特定のグループメンバー', - organization: 'グループ内の全員', + anyone: 'リンクを知っているすべてのユーザー', + specific: '特定のプラットフォーム内メンバー', + organization: 'プラットフォーム内の全メンバー', + external: '認証済みの外部ユーザー', }, groups_one: '{{count}} グループ', groups_other: '{{count}} グループ', members_one: '{{count}} メンバー', members_other: '{{count}} メンバー', noGroupsOrMembers: 'グループまたはメンバーが選択されていません', - webAppSSONotEnabledTip: 'Web アプリの認証方式設定については、企業管理者へご連絡ください。', + webAppSSONotEnabledTip: 'Web アプリの外部認証方式を設定するには、組織の管理者にお問い合わせください。', operateGroupAndMember: { searchPlaceholder: 'グループやメンバーを検索', allMembers: 'すべてのメンバー', diff --git a/web/i18n/ja-JP/share-app.ts b/web/i18n/ja-JP/share-app.ts index 9e76f6518..20dad7fae 100644 --- a/web/i18n/ja-JP/share-app.ts +++ b/web/i18n/ja-JP/share-app.ts @@ -73,6 +73,9 @@ const translation = { atLeastOne: '1 行以上のデータが必要です', }, }, + login: { + backToHome: 'ホームに戻る', + }, } export default translation diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index bdd7d98d9..4ec1e6505 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -198,30 +198,27 @@ const translation = { }, accessControl: 'Web 应用访问控制', accessItemsDescription: { - anyone: '任何人可以访问 web 应用', - specific: '特定组或成员可以访问 web 应用', - organization: '组织内任何人可以访问 web 应用', + anyone: '任何人都可以访问该 web 应用(无需登录)', + specific: '仅指定的平台内成员可访问该 Web 应用', + organization: '平台内所有成员均可访问该 Web 应用', + external: '仅经认证的外部用户可访问该 Web 应用', }, accessControlDialog: { title: 'Web 应用访问权限', description: '设置 web 应用访问权限。', accessLabel: '谁可以访问', - accessItemsDescription: { - anyone: '任何人可以访问 web 应用', - specific: '特定组或成员可以访问 web 应用', - organization: '组织内任何人可以访问 web 应用', - }, accessItems: { anyone: '任何人', - specific: '特定组或成员', - organization: '组织内任何人', + specific: '平台内指定成员', + organization: '平台内所有成员', + external: '经认证的外部用户', }, groups_one: '{{count}} 个组', groups_other: '{{count}} 个组', members_one: '{{count}} 个成员', members_other: '{{count}} 个成员', noGroupsOrMembers: '未选择分组或成员', - webAppSSONotEnabledTip: '请联系企业管理员配置 web 应用的身份认证方式。', + webAppSSONotEnabledTip: '请联系企业管理员配置 Web 应用外部认证方式。', operateGroupAndMember: { searchPlaceholder: '搜索组或成员', allMembers: '所有成员', diff --git a/web/i18n/zh-Hans/share-app.ts b/web/i18n/zh-Hans/share-app.ts index 4ea2ad6f4..ce1270dae 100644 --- a/web/i18n/zh-Hans/share-app.ts +++ b/web/i18n/zh-Hans/share-app.ts @@ -73,6 +73,9 @@ const translation = { atLeastOne: '上传文件的内容不能少于一条', }, }, + login: { + backToHome: '返回首页', + }, } export default translation diff --git a/web/models/access-control.ts b/web/models/access-control.ts index 8ad9cc649..911662b5c 100644 --- a/web/models/access-control.ts +++ b/web/models/access-control.ts @@ -7,6 +7,7 @@ export enum AccessMode { PUBLIC = 'public', SPECIFIC_GROUPS_MEMBERS = 'private', ORGANIZATION = 'private_all', + EXTERNAL_MEMBERS = 'sso_verified', } export type AccessControlGroup = { diff --git a/web/service/base.ts b/web/service/base.ts index 4b0873628..c3cafe600 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -109,6 +109,7 @@ function unicodeToChar(text: string) { } function requiredWebSSOLogin(message?: string) { + removeAccessToken() const params = new URLSearchParams() params.append('redirect_url', globalThis.location.pathname) if (message) diff --git a/web/service/common.ts b/web/service/common.ts index e76cfb419..700cd4bf5 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -52,6 +52,9 @@ type LoginResponse = LoginSuccess | LoginFail export const login: Fetcher }> = ({ url, body }) => { return post(url, { body }) as Promise } +export const webAppLogin: Fetcher }> = ({ url, body }) => { + return post(url, { body }, { isPublicAPI: true }) as Promise +} export const fetchNewToken: Fetcher }> = ({ body }) => { return post('/refresh-token', { body }) as Promise @@ -324,6 +327,16 @@ export const verifyForgotPasswordToken: Fetcher = ({ url, body }) => post(url, { body }) +export const sendWebAppForgotPasswordEmail: Fetcher = ({ url, body }) => + post(url, { body }, { isPublicAPI: true }) + +export const verifyWebAppForgotPasswordToken: Fetcher = ({ url, body }) => { + return post(url, { body }, { isPublicAPI: true }) as Promise +} + +export const changeWebAppPasswordWithToken: Fetcher = ({ url, body }) => + post(url, { body }, { isPublicAPI: true }) + export const uploadRemoteFileInfo = (url: string, isPublic?: boolean) => { return post<{ id: string; name: string; size: number; mime_type: string; url: string }>('/remote-files/upload', { body: { url } }, { isPublicAPI: isPublic }) } @@ -340,6 +353,18 @@ export const sendResetPasswordCode = (email: string, language = 'en-US') => export const verifyResetPasswordCode = (body: { email: string; code: string; token: string }) => post('/forgot-password/validity', { body }) +export const sendWebAppEMailLoginCode = (email: string, language = 'en-US') => + post('/email-code-login', { body: { email, language } }, { isPublicAPI: true }) + +export const webAppEmailLoginWithCode = (data: { email: string; code: string; token: string }) => + post('/email-code-login/validity', { body: data }, { isPublicAPI: true }) + +export const sendWebAppResetPasswordCode = (email: string, language = 'en-US') => + post('/forgot-password', { body: { email, language } }, { isPublicAPI: true }) + +export const verifyWebAppResetPasswordCode = (body: { email: string; code: string; token: string }) => + post('/forgot-password/validity', { body }, { isPublicAPI: true }) + export const sendDeleteAccountCode = () => get('/account/delete/verify') diff --git a/web/service/share.ts b/web/service/share.ts index 7fb156218..6a2a7e5b1 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -214,6 +214,34 @@ export const fetchWebOAuth2SSOUrl = async (appCode: string, redirectUrl: string) }) as Promise<{ url: string }> } +export const fetchMembersSAMLSSOUrl = async (appCode: string, redirectUrl: string) => { + return (getAction('get', false))(getUrl('/enterprise/sso/members/saml/login', false, ''), { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, + }) as Promise<{ url: string }> +} + +export const fetchMembersOIDCSSOUrl = async (appCode: string, redirectUrl: string) => { + return (getAction('get', false))(getUrl('/enterprise/sso/members/oidc/login', false, ''), { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, + + }) as Promise<{ url: string }> +} + +export const fetchMembersOAuth2SSOUrl = async (appCode: string, redirectUrl: string) => { + return (getAction('get', false))(getUrl('/enterprise/sso/members/oauth2/login', false, ''), { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, + }) as Promise<{ url: string }> +} + export const fetchAppMeta = async (isInstalledApp: boolean, installedAppId = '') => { return (getAction('get', isInstalledApp))(getUrl('meta', isInstalledApp, installedAppId)) as Promise } @@ -258,10 +286,13 @@ export const textToAudioStream = (url: string, isPublicAPI: boolean, header: { c return (getAction('post', !isPublicAPI))(url, { body, header }, { needAllResponseContent: true }) } -export const fetchAccessToken = async (appCode: string, userId?: string) => { +export const fetchAccessToken = async ({ appCode, userId, webAppAccessToken }: { appCode: string, userId?: string, webAppAccessToken?: string | null }) => { const headers = new Headers() headers.append('X-App-Code', appCode) - const url = userId ? `/passport?user_id=${encodeURIComponent(userId)}` : '/passport' + const params = new URLSearchParams() + webAppAccessToken && params.append('web_app_access_token', webAppAccessToken) + userId && params.append('user_id', userId) + const url = `/passport?${params.toString()}` return get(url, { headers }) as Promise<{ access_token: string }> } @@ -278,3 +309,7 @@ export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => { return get<{ result: boolean }>(`/webapp/permission?appId=${appId}`) } + +export const getAppAccessModeByAppCode = (appCode: string) => { + return get<{ accessMode: AccessMode }>(`/webapp/access-mode?appCode=${appCode}`) +} diff --git a/web/service/use-share.ts b/web/service/use-share.ts new file mode 100644 index 000000000..b8f96f6cc --- /dev/null +++ b/web/service/use-share.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' +import { getAppAccessModeByAppCode } from './share' + +const NAME_SPACE = 'webapp' + +export const useAppAccessModeByCode = (code: string | null) => { + return useQuery({ + queryKey: [NAME_SPACE, 'appAccessMode', code], + queryFn: () => { + if (!code) + return null + + return getAppAccessModeByAppCode(code) + }, + enabled: !!code, + }) +}