diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx index ab8c2b97db..f483e34001 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx @@ -20,7 +20,7 @@ import cn from '@/utils/classnames' import { useStore } from '@/app/components/app/store' import AppSideBar from '@/app/components/app-sidebar' import type { NavIcon } from '@/app/components/app-sidebar/navLink' -import { fetchAppDetail, fetchAppSSO } from '@/service/apps' +import { fetchAppDetail } from '@/service/apps' import { useAppContext } from '@/context/app-context' import Loading from '@/app/components/base/loading' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -32,6 +32,13 @@ export type IAppDetailLayoutProps = { params: { appId: string } } +type NavigationType = { + name: string + href: string + icon: NavIcon + selectedIcon: NavIcon +} + const AppDetailLayout: FC = (props) => { const { children, @@ -50,12 +57,7 @@ const AppDetailLayout: FC = (props) => { }))) const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false) const [appDetailRes, setAppDetailRes] = useState(null) - const [navigation, setNavigation] = useState>([]) + const [navigation, setNavigation] = useState>([]) const { systemFeatures } = useGlobalPublicStore() const getNavigations = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: string) => { @@ -142,15 +144,10 @@ const AppDetailLayout: FC = (props) => { router.replace(`/app/${appId}/configuration`) } else { - setAppDetail({ ...res, enable_sso: false }) - setNavigation(getNavigations(appId, isCurrentWorkspaceEditor, res.mode)) - if (systemFeatures.enable_web_sso_switch_component && canIEditApp) { - fetchAppSSO({ appId }).then((ssoRes) => { - setAppDetail({ ...res, enable_sso: ssoRes.enabled }) - }) - } + setAppDetail({ ...res }) + setNavigation(getNavigations(appId, isCurrentWorkspaceEditor, res.mode) as Array) } - }, [appDetailRes, appId, getNavigations, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace, pathname, router, setAppDetail, systemFeatures.enable_web_sso_switch_component]) + }, [appDetailRes, appId, getNavigations, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace, pathname, router, setAppDetail]) useUnmount(() => { setAppDetail() diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx index 1285b2205c..02d858c01b 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx @@ -8,19 +8,16 @@ import Loading from '@/app/components/base/loading' import { ToastContext } from '@/app/components/base/toast' import { fetchAppDetail, - fetchAppSSO, - updateAppSSO, updateAppSiteAccessToken, updateAppSiteConfig, updateAppSiteStatus, } from '@/service/apps' -import type { App, AppSSO } from '@/types/app' +import type { App } from '@/types/app' import type { UpdateAppSiteCodeResponse } from '@/models/app' import { asyncRunSafe } from '@/utils' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import type { IAppCardProps } from '@/app/components/app/overview/appCard' import { useStore as useAppStore } from '@/app/components/app/store' -import { useGlobalPublicStore } from '@/context/global-public-context' export type ICardViewProps = { appId: string @@ -31,18 +28,11 @@ const CardView: FC = ({ appId }) => { const { notify } = useContext(ToastContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) - const { systemFeatures } = useGlobalPublicStore() const updateAppDetail = async () => { try { const res = await fetchAppDetail({ url: '/apps', id: appId }) - if (systemFeatures.enable_web_sso_switch_component) { - const ssoRes = await fetchAppSSO({ appId }) - setAppDetail({ ...res, enable_sso: ssoRes.enabled }) - } - else { - setAppDetail({ ...res }) - } + setAppDetail({ ...res }) } catch (error) { console.error(error) } } @@ -93,16 +83,6 @@ const CardView: FC = ({ appId }) => { if (!err) localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') - if (systemFeatures.enable_web_sso_switch_component) { - const [sso_err] = await asyncRunSafe( - updateAppSSO({ id: appId, enabled: Boolean(params.enable_sso) }) as Promise, - ) - if (sso_err) { - handleCallbackResult(sso_err) - return - } - } - handleCallbackResult(err) } diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index dabe75ee62..b81d392fd4 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, useState } from 'react' import { useTranslation } from 'react-i18next' -import { RiMoreFill } from '@remixicon/react' +import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react' import s from './style.module.css' import cn from '@/utils/classnames' import type { App } from '@/types/app' @@ -31,6 +31,9 @@ import DSLExportConfirmModal from '@/app/components/workflow/dsl-export-confirm- import { fetchWorkflowDraft } from '@/service/workflow' import { fetchInstalledAppList } from '@/service/explore' import { AppTypeIcon } from '@/app/components/app/type-selector' +import Tooltip from '@/app/components/base/tooltip' +import AccessControl from '@/app/components/app/app-access-control' +import { AccessMode } from '@/models/access-control' export type AppCardProps = { app: App @@ -53,6 +56,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { const [showDuplicateModal, setShowDuplicateModal] = useState(false) const [showSwitchModal, setShowSwitchModal] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) + const [showAccessControl, setShowAccessControl] = useState(false) const [secretEnvList, setSecretEnvList] = useState([]) const onConfirmDelete = useCallback(async () => { @@ -71,7 +75,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { }) } setShowConfirmDelete(false) - }, [app.id]) + }, [app.id, mutateApps, notify, onPlanInfoChanged, onRefresh, t]) const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, @@ -175,6 +179,13 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { setShowSwitchModal(false) } + const onUpdateAccessControl = useCallback(() => { + if (onRefresh) + onRefresh() + mutateApps() + setShowAccessControl(false) + }, [onRefresh, mutateApps, setShowAccessControl]) + const Operations = (props: HtmlContentProps) => { const onMouseLeave = async () => { props.onClose?.() @@ -209,6 +220,12 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { e.preventDefault() setShowConfirmDelete(true) } + const onClickAccessControl = async (e: React.MouseEvent) => { + e.stopPropagation() + props.onClick?.() + e.preventDefault() + setShowAccessControl(true) + } const onClickInstalledApp = async (e: React.MouseEvent) => { e.stopPropagation() props.onClick?.() @@ -252,6 +269,14 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { {t('app.openInExplore')} + { + isCurrentWorkspaceEditor && <> + + + + }
{ }} className='relative h-[160px] group col-span-1 bg-components-card-bg border-[1px] border-solid border-components-card-border rounded-xl shadow-sm inline-flex flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg' > -
+
{ {app.mode === 'completion' &&
{t('app.types.completion').toUpperCase()}
}
+
+ {app.access_mode === AccessMode.PUBLIC && + + } + {app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && + + } + {app.access_mode === AccessMode.ORGANIZATION && + + } +
{ popupClassName={ (app.mode === 'completion' || app.mode === 'chat') ? '!w-[256px] translate-x-[-224px]' - : '!w-[160px] translate-x-[-128px]' + : '!w-[216px] translate-x-[-128px]' } className={'h-fit !z-20'} /> @@ -418,6 +454,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { onClose={() => setSecretEnvList([])} /> )} + {showAccessControl && ( + setShowAccessControl(false)} /> + )} ) } diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 12f4152c6f..d8a1267c6f 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -1,14 +1,21 @@ 'use client' import { useRouter, useSearchParams } from 'next/navigation' import type { FC } from 'react' -import React, { useEffect } 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 { fetchSystemFeatures, fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' +import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' import { setAccessToken } from '@/app/components/share/utils' +import Button from '@/app/components/base/button' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { SSOProtocol } from '@/types/feature' import Loading from '@/app/components/base/loading' const WebSSOForm: FC = () => { + const { t } = useTranslation() + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const searchParams = useSearchParams() const router = useRouter() @@ -23,15 +30,15 @@ const WebSSOForm: FC = () => { }) } - const getAppCodeFromRedirectUrl = () => { + const getAppCodeFromRedirectUrl = useCallback(() => { const appCode = redirectUrl?.split('/').pop() if (!appCode) return null return appCode - } + }, [redirectUrl]) - const processTokenAndRedirect = async () => { + const processTokenAndRedirect = useCallback(async () => { const appCode = getAppCodeFromRedirectUrl() if (!appCode || !tokenFromUrl || !redirectUrl) { showErrorToast('redirect url or app code or token is invalid.') @@ -40,27 +47,27 @@ const WebSSOForm: FC = () => { await setAccessToken(appCode, tokenFromUrl) router.push(redirectUrl) - } + }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl]) - const handleSSOLogin = async (protocol: string) => { + const handleSSOLogin = async () => { const appCode = getAppCodeFromRedirectUrl() if (!appCode || !redirectUrl) { showErrorToast('redirect url or app code is invalid.') return } - switch (protocol) { - case 'saml': { + switch (systemFeatures.webapp_auth.sso_config.protocol) { + case SSOProtocol.SAML: { const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) router.push(samlRes.url) break } - case 'oidc': { + case SSOProtocol.OIDC: { const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) router.push(oidcRes.url) break } - case 'oauth2': { + case SSOProtocol.OAuth2: { const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) router.push(oauth2Res.url) break @@ -72,32 +79,52 @@ const WebSSOForm: FC = () => { useEffect(() => { const init = async () => { - const res = await fetchSystemFeatures() - const protocol = res.sso_enforced_for_web_protocol - if (message) { showErrorToast(message) return } - if (!tokenFromUrl) { - await handleSSOLogin(protocol) + if (!tokenFromUrl) return - } await processTokenAndRedirect() } init() - }, [message, tokenFromUrl]) // Added dependencies to useEffect + }, [message, processTokenAndRedirect, tokenFromUrl]) + if (tokenFromUrl) + return
- return ( -
-
- + if (systemFeatures.webapp_auth.enabled) { + if (systemFeatures.webapp_auth.allow_sso) { + return ( +
+
+ +
+
+ ) + } + return
+
+
+ +
+

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

+

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

+
+
+
- ) + } + else { + return
+

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

+
+ } } export default React.memo(WebSSOForm) diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index 12f9c59cd1..1d791b9d15 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -5,6 +5,7 @@ import { RiArrowDownSLine } from '@remixicon/react' import React, { useCallback, useState } from 'react' import AppIcon from '../base/app-icon' import SwitchAppModal from '../app/switch-app-modal' +import AccessControl from '../app/app-access-control' import s from './style.module.css' import cn from '@/utils/classnames' import { @@ -18,7 +19,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { ToastContext } from '@/app/components/base/toast' import AppsContext, { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' -import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' +import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps' import DuplicateAppModal from '@/app/components/app/duplicate-modal' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import CreateAppModal from '@/app/components/explore/create-app-modal' @@ -50,6 +51,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => { const [showSwitchTip, setShowSwitchTip] = useState('') const [showSwitchModal, setShowSwitchModal] = useState(false) const [showImportDSLModal, setShowImportDSLModal] = useState(false) + const [showAccessControl, setShowAccessControl] = useState(false) const [secretEnvList, setSecretEnvList] = useState([]) const mutateApps = useContextSelector( @@ -175,7 +177,20 @@ const AppInfo = ({ expand }: IAppInfoProps) => { }) } setShowConfirmDelete(false) - }, [appDetail, mutateApps, notify, onPlanInfoChanged, replace, t]) + }, [appDetail, mutateApps, notify, onPlanInfoChanged, replace, setAppDetail, t]) + + const handleClickAccessControl = useCallback(() => { + if (!appDetail) + return + setShowAccessControl(true) + setOpen(false) + }, [appDetail]) + const handleAccessControlUpdate = useCallback(() => { + fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => { + setAppDetail(res) + setShowAccessControl(false) + }) + }, [appDetail, setAppDetail]) const { isCurrentWorkspaceEditor } = useAppContext() @@ -374,6 +389,10 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
) } + +
+ {t('app.accessControl')} +
{ setOpen(false) @@ -466,6 +485,11 @@ const AppInfo = ({ expand }: IAppInfoProps) => { onClose={() => setSecretEnvList([])} /> )} + { + showAccessControl && { setShowAccessControl(false) }} /> + }
) diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index 61e4bf8330..7b0e8d9294 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -17,7 +17,7 @@ export type IAppDetailNavProps = { desc: string isExternal?: boolean icon: string - icon_background: string + icon_background: string | null navigation: Array<{ name: string href: string diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx new file mode 100644 index 0000000000..f93973b9a6 --- /dev/null +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -0,0 +1,61 @@ +import { Fragment, useCallback } from 'react' +import type { ReactNode } from 'react' +import { Dialog, Transition } from '@headlessui/react' +import { RiCloseLine } from '@remixicon/react' +import cn from '@/utils/classnames' + +type DialogProps = { + className?: string + children: ReactNode + show: boolean + onClose?: () => void +} + +const AccessControlDialog = ({ + className, + children, + show, + onClose, +}: DialogProps) => { + const close = useCallback(() => { + onClose?.() + }, [onClose]) + return ( + + null}> + +
+ + +
+ + +
close()} className="absolute top-5 right-5 w-8 h-8 flex items-center justify-center cursor-pointer z-10"> + +
+ {children} +
+
+
+
+
+ ) +} + +export default AccessControlDialog diff --git a/web/app/components/app/app-access-control/access-control-item.tsx b/web/app/components/app/app-access-control/access-control-item.tsx new file mode 100644 index 0000000000..9e5c7ca5a7 --- /dev/null +++ b/web/app/components/app/app-access-control/access-control-item.tsx @@ -0,0 +1,30 @@ +'use client' +import type { FC, PropsWithChildren } from 'react' +import useAccessControlStore from '../../../../context/access-control-store' +import type { AccessMode } from '@/models/access-control' + +type AccessControlItemProps = PropsWithChildren<{ + type: AccessMode +}> + +const AccessControlItem: FC = ({ type, children }) => { + const { currentMenu, setCurrentMenu } = useAccessControlStore(s => ({ currentMenu: s.currentMenu, setCurrentMenu: s.setCurrentMenu })) + if (currentMenu !== type) { + return
setCurrentMenu(type)} > + {children} +
+ } + + return
+ {children} +
+} + +AccessControlItem.displayName = 'AccessControlItem' + +export default AccessControlItem diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx new file mode 100644 index 0000000000..5a379b4341 --- /dev/null +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -0,0 +1,202 @@ +'use client' +import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDebounce } from 'ahooks' +import Avatar from '../../base/avatar' +import Button from '../../base/button' +import Checkbox from '../../base/checkbox' +import Input from '../../base/input' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem' +import Loading from '../../base/loading' +import useAccessControlStore from '../../../../context/access-control-store' +import classNames from '@/utils/classnames' +import { useSearchForWhiteListCandidates } from '@/service/access-control' +import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control' +import { SubjectType } from '@/models/access-control' +import { useSelector } from '@/context/app-context' + +export default function AddMemberOrGroupDialog() { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [keyword, setKeyword] = useState('') + const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) + const debouncedKeyword = useDebounce(keyword, { wait: 500 }) + + const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1] + const { isPending, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open) + const handleKeywordChange = (e: React.ChangeEvent) => { + setKeyword(e.target.value) + } + + const anchorRef = useRef(null) + useEffect(() => { + const hasMore = data?.pages?.[0].hasMore ?? false + let observer: IntersectionObserver | undefined + if (anchorRef.current) { + observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isPending && hasMore) + fetchNextPage() + }, { rootMargin: '20px' }) + observer.observe(anchorRef.current) + } + return () => observer?.disconnect() + }, [isPending, fetchNextPage, anchorRef, data]) + + return + + + + +
+
+ +
+ { + isPending + ?
+ : (data?.pages?.length ?? 0) > 0 + ? <> +
+ +
+
+ {renderGroupOrMember(data?.pages ?? [])} + {isFetchingNextPage && } +
+
+ + :
+ {t('app.accessControlDialog.operateGroupAndMember.noResult')} +
+ } +
+
+
+} + +type GroupOrMemberData = { subjects: Subject[]; currPage: number }[] +function renderGroupOrMember(data: GroupOrMemberData) { + return data?.map((page) => { + return
+ {page.subjects?.map((item, index) => { + if (item.subjectType === SubjectType.GROUP) + return + return + })} +
+ }) ?? null +} + +function SelectedGroupsBreadCrumb() { + const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) + const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) + const { t } = useTranslation() + + const handleBreadCrumbClick = useCallback((index: number) => { + const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1) + setSelectedGroupsForBreadcrumb(newGroups) + }, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb]) + const handleReset = useCallback(() => { + setSelectedGroupsForBreadcrumb([]) + }, [setSelectedGroupsForBreadcrumb]) + return
+ 0 && 'text-text-accent cursor-pointer')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')} + {selectedGroupsForBreadcrumb.map((group, index) => { + return
+ / + handleBreadCrumbClick(index)}>{group.name} +
+ })} +
+} + +type GroupItemProps = { + group: AccessControlGroup +} +function GroupItem({ group }: GroupItemProps) { + const { t } = useTranslation() + const specificGroups = useAccessControlStore(s => s.specificGroups) + const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) + const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) + const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) + const isChecked = specificGroups.some(g => g.id === group.id) + const handleCheckChange = useCallback(() => { + if (!isChecked) { + const newGroups = [...specificGroups, group] + setSpecificGroups(newGroups) + } + else { + const newGroups = specificGroups.filter(g => g.id !== group.id) + setSpecificGroups(newGroups) + } + }, [specificGroups, setSpecificGroups, group, isChecked]) + + const handleExpandClick = useCallback(() => { + setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group]) + }, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group]) + return + +
+
+
+ +
+
+

{group.name}

+

{group.groupSize}

+
+ +
+} + +type MemberItemProps = { + member: AccessControlAccount +} +function MemberItem({ member }: MemberItemProps) { + const currentUser = useSelector(s => s.userProfile) + const { t } = useTranslation() + const specificMembers = useAccessControlStore(s => s.specificMembers) + const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) + const isChecked = specificMembers.some(m => m.id === member.id) + const handleCheckChange = useCallback(() => { + if (!isChecked) { + const newMembers = [...specificMembers, member] + setSpecificMembers(newMembers) + } + else { + const newMembers = specificMembers.filter(m => m.id !== member.id) + setSpecificMembers(newMembers) + } + }, [specificMembers, setSpecificMembers, member, isChecked]) + return + +
+
+
+ +
+
+

{member.name}

+ {currentUser.email === member.email &&

({t('common.you')})

} +
+

{member.email}

+
+} + +type BaseItemProps = { + className?: string + children: React.ReactNode +} +function BaseItem({ children, className }: BaseItemProps) { + return
+ {children} +
+} diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx new file mode 100644 index 0000000000..a5f0aa9b2c --- /dev/null +++ b/web/app/components/app/app-access-control/index.tsx @@ -0,0 +1,102 @@ +'use client' +import { Dialog } from '@headlessui/react' +import { RiBuildingLine, RiGlobalLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useCallback, useEffect } from 'react' +import Button from '../../base/button' +import Toast from '../../base/toast' +import useAccessControlStore from '../../../../context/access-control-store' +import AccessControlDialog from './access-control-dialog' +import AccessControlItem from './access-control-item' +import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members' +import { useGlobalPublicStore } from '@/context/global-public-context' +import type { App } from '@/types/app' +import type { Subject } from '@/models/access-control' +import { AccessMode, SubjectType } from '@/models/access-control' +import { useUpdateAccessMode } from '@/service/access-control' + +type AccessControlProps = { + app: App + onClose: () => void + onConfirm?: () => void +} + +export default function AccessControl(props: AccessControlProps) { + const { app, onClose, onConfirm } = props + const { t } = useTranslation() + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const setAppId = useAccessControlStore(s => s.setAppId) + const specificGroups = useAccessControlStore(s => s.specificGroups) + const specificMembers = useAccessControlStore(s => s.specificMembers) + const currentMenu = useAccessControlStore(s => s.currentMenu) + const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu) + 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) + + useEffect(() => { + setAppId(app.id) + setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS) + }, [app, setAppId, setCurrentMenu]) + + const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode() + const handleConfirm = useCallback(async () => { + const submitData: { + appId: string + accessMode: AccessMode + subjects?: Pick[] + } = { appId: app.id, accessMode: currentMenu } + if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) { + const subjects: Pick[] = [] + specificGroups.forEach((group) => { + subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP }) + }) + specificMembers.forEach((member) => { + subjects.push({ + subjectId: member.id, + subjectType: SubjectType.ACCOUNT, + }) + }) + submitData.subjects = subjects + } + await updateAccessMode(submitData) + Toast.notify({ type: 'success', message: t('app.accessControlDialog.updateSuccess') }) + onConfirm?.() + }, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu]) + return +
+
+ {t('app.accessControlDialog.title')} + {t('app.accessControlDialog.description')} +
+
+
+

{t('app.accessControlDialog.accessLabel')}

+
+ +
+
+ +

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

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

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

+
+
+
+
+ + +
+
+
+} 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 new file mode 100644 index 0000000000..0fb3173d6e --- /dev/null +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -0,0 +1,139 @@ +'use client' +import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react' +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' + +export default function SpecificGroupsOrMembers() { + const currentMenu = useAccessControlStore(s => s.currentMenu) + const appId = useAccessControlStore(s => s.appId) + 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(() => { + setSpecificGroups(data?.groups ?? []) + setSpecificMembers(data?.members ?? []) + }, [data, setSpecificGroups, setSpecificMembers]) + + if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) { + return
+
+ +

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

+
+ {!hideTip && } +
+ } + + return
+
+
+ +

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

+
+
+ {!hideTip && <> + + + } + +
+
+
+
+ {isPending ? : } +
+
+
+} + +function RenderGroupsAndMembers() { + const { t } = useTranslation() + const specificGroups = useAccessControlStore(s => s.specificGroups) + const specificMembers = useAccessControlStore(s => s.specificMembers) + if (specificGroups.length <= 0 && specificMembers.length <= 0) + return

{t('app.accessControlDialog.noGroupsOrMembers')}

+ return <> +

{t('app.accessControlDialog.groups', { count: specificGroups.length ?? 0 })}

+
+ {specificGroups.map((group, index) => )} +
+

{t('app.accessControlDialog.members', { count: specificMembers.length ?? 0 })}

+
+ {specificMembers.map((member, index) => )} +
+ +} + +type GroupItemProps = { + group: AccessControlGroup +} +function GroupItem({ group }: GroupItemProps) { + const specificGroups = useAccessControlStore(s => s.specificGroups) + const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) + const handleRemoveGroup = useCallback(() => { + setSpecificGroups(specificGroups.filter(g => g.id !== group.id)) + }, [group, setSpecificGroups, specificGroups]) + return } + onRemove={handleRemoveGroup}> +

{group.name}

+

{group.groupSize}

+
+} + +type MemberItemProps = { + member: AccessControlAccount +} +function MemberItem({ member }: MemberItemProps) { + const specificMembers = useAccessControlStore(s => s.specificMembers) + const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) + const handleRemoveMember = useCallback(() => { + setSpecificMembers(specificMembers.filter(m => m.id !== member.id)) + }, [member, setSpecificMembers, specificMembers]) + return } + onRemove={handleRemoveMember}> +

{member.name}

+
+} + +type BaseItemProps = { + icon: React.ReactNode + children: React.ReactNode + onRemove?: () => void +} +function BaseItem({ icon, onRemove, children }: BaseItemProps) { + return
+
+
+ {icon} +
+
+ {children} +
+ +
+
+} + +export function WebAppSSONotEnabledTip() { + const { t } = useTranslation() + return + + +} diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 3ba35a7336..0385099545 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,13 +1,18 @@ import { memo, useCallback, + useEffect, useState, } from 'react' import { useTranslation } from 'react-i18next' import dayjs from 'dayjs' -import { RiArrowDownSLine, RiPlanetLine } from '@remixicon/react' +import { RiArrowDownSLine, RiArrowRightSLine, RiLockLine, RiPlanetLine } from '@remixicon/react' import Toast from '../../base/toast' import type { ModelAndParameter } from '../configuration/debug/types' +import Divider from '../../base/divider' +import AccessControl from '../app-access-control' +import Loading from '../../base/loading' +import Tooltip from '../../base/tooltip' import SuggestedAction from './suggested-action' import PublishWithMultipleModel from './publish-with-multiple-model' import Button from '@/app/components/base/button' @@ -27,6 +32,9 @@ import { FileText } from '@/app/components/base/icons/src/vender/line/files' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' import type { InputVar } from '@/app/components/workflow/types' import { appDefaultIconBackground } from '@/config' +import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' +import { AccessMode } from '@/models/access-control' +import { fetchAppDetail } from '@/service/apps' export type AppPublisherProps = { disabled?: boolean @@ -65,10 +73,31 @@ const AppPublisher = ({ const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) const appDetail = useAppStore(state => state.appDetail) + const setAppDetail = useAppStore(s => s.setAppDetail) const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode const appURL = `${appBaseURL}/${appMode}/${accessToken}` + const { data: useCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) + const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) + useEffect(() => { + if (open && appDetail) + refetch() + }, [open, appDetail, refetch]) + + const [showAppAccessControl, setShowAppAccessControl] = useState(false) + const [isAppAccessSet, setIsAppAccessSet] = useState(true) + useEffect(() => { + if (appDetail && appAccessSubjects) { + if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) + setIsAppAccessSet(false) + else + setIsAppAccessSet(true) + } + else { + setIsAppAccessSet(true) + } + }, [appAccessSubjects, appDetail]) const language = useGetLanguage() const formatTimeFromNow = useCallback((time: number) => { return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow() @@ -120,6 +149,13 @@ const AppPublisher = ({ } }, [appDetail?.id]) + const handleAccessControlUpdate = useCallback(() => { + fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => { + setAppDetail(res) + setShowAppAccessControl(false) + }) + }, [appDetail, setAppDetail]) + const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) return ( @@ -196,58 +232,95 @@ const AppPublisher = ({ ) }
-
- }>{t('workflow.common.runApp')} - {appDetail?.mode === 'workflow' - ? ( - } - > - {t('workflow.common.batchRunApp')} - - ) - : ( -
+ : <> + +
+
+

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

+
+
{ - setEmbeddingModalOpen(true) - handleTrigger() - }} - disabled={!publishedAt} - icon={} - > - {t('workflow.common.embedIntoSite')} - - )} - { - handleOpenInExplore() - }} - disabled={!publishedAt} - icon={} - > - {t('workflow.common.openInExplore')} - - }>{t('workflow.common.accessAPIReference')} - {appDetail?.mode === 'workflow' && ( - - )} -
+ 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')}

} +
+ {!isAppAccessSet &&

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

} +
+ +
+
+ {!isAppAccessSet &&

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

} +
+
+ + }>{t('workflow.common.runApp')} + + {appDetail?.mode === 'workflow' + ? (
+ } + > + {t('workflow.common.batchRunApp')} + +
+ ) + : (
+ { + setEmbeddingModalOpen(true) + handleTrigger() + }} + disabled={!publishedAt} + icon={} + > + {t('workflow.common.embedIntoSite')} + +
+ )} + + { + handleOpenInExplore() + }} + disabled={!publishedAt || !useCanAccessApp?.result} + icon={} + > + {t('workflow.common.openInExplore')} + + +
+ }>{t('workflow.common.accessAPIReference')} +
+ + {appDetail?.mode === 'workflow' && ( +
+ +
+ )} +
+ }
+ {showAppAccessControl && { setShowAppAccessControl(false) }} />} ) } diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx index a371eafde0..368ff6d2c5 100644 --- a/web/app/components/app/app-publisher/suggested-action.tsx +++ b/web/app/components/app/app-publisher/suggested-action.tsx @@ -8,22 +8,30 @@ export type SuggestedActionProps = PropsWithChildren -const SuggestedAction = ({ icon, link, disabled, children, className, ...props }: SuggestedActionProps) => ( - -
{icon}
-
{children}
- -
-) +const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => { + const handleClick = (e: React.MouseEvent) => { + if (disabled) + return + onClick?.(e) + } + return ( + +
{icon}
+
{children}
+ +
+ ) +} export default SuggestedAction diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index 357d7c0196..f7e2d964ed 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -21,14 +21,12 @@ import type { AppIconType, AppSSO, Language } from '@/types/app' import { useToastContext } from '@/app/components/base/toast' import { LanguagesSupported, languages } from '@/i18n/language' import Tooltip from '@/app/components/base/tooltip' -import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { useModalContext } from '@/context/modal-context' import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import AppIconPicker from '@/app/components/base/app-icon-picker' import I18n from '@/context/i18n' import cn from '@/utils/classnames' -import { useGlobalPublicStore } from '@/context/global-public-context' export type ISettingsModalProps = { isChat: boolean @@ -66,8 +64,6 @@ const SettingsModal: FC = ({ onClose, onSave, }) => { - const { systemFeatures } = useGlobalPublicStore() - const { isCurrentWorkspaceEditor } = useAppContext() const { notify } = useToastContext() const [isShowMore, setIsShowMore] = useState(false) const { @@ -139,7 +135,7 @@ const SettingsModal: FC = ({ setAppIcon(icon_type === 'image' ? { type: 'image', url: icon_url!, fileId: icon } : { type: 'emoji', icon, background: icon_background! }) - }, [appInfo]) + }, [appInfo, chat_color_theme, chat_color_theme_inverted, copyright, custom_disclaimer, default_language, description, icon, icon_background, icon_type, icon_url, privacy_policy, show_workflow_steps, title, use_icon_as_answer_icon]) const onHide = () => { onClose() @@ -325,28 +321,6 @@ const SettingsModal: FC = ({

{t(`${prefixSettings}.workflow.showDesc`)}

- {/* SSO */} - {systemFeatures.enable_web_sso_switch_component && ( - <> - -
-

{t(`${prefixSettings}.sso.label`)}

-
-
{t(`${prefixSettings}.sso.title`)}
- {t(`${prefixSettings}.sso.tooltip`)}
- } - asChild={false} - > - setInputInfo({ ...inputInfo, enable_sso: v })}> - -
-

{t(`${prefixSettings}.sso.description`)}

- - - )} {/* more settings switch */} {!isShowMore && ( diff --git a/web/app/components/base/app-unavailable.tsx b/web/app/components/base/app-unavailable.tsx index b8b42108a9..ea20464b1b 100644 --- a/web/app/components/base/app-unavailable.tsx +++ b/web/app/components/base/app-unavailable.tsx @@ -17,7 +17,7 @@ const AppUnavailable: FC = ({ const { t } = useTranslation() return ( -
+

({ + accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + userCanAccess: false, currentConversationId: '', appPrevChatTree: [], pinnedConversationList: [], @@ -59,21 +64,21 @@ export const ChatWithHistoryContext = createContext showConfigPanelBeforeChat: false, newConversationInputs: {}, newConversationInputsRef: { current: {} }, - handleNewConversationInputsChange: () => {}, + handleNewConversationInputsChange: () => { }, inputsForms: [], - handleNewConversation: () => {}, - handleStartChat: () => {}, - handleChangeConversation: () => {}, - handlePinConversation: () => {}, - handleUnpinConversation: () => {}, - handleDeleteConversation: () => {}, + handleNewConversation: () => { }, + handleStartChat: () => { }, + handleChangeConversation: () => { }, + handlePinConversation: () => { }, + handleUnpinConversation: () => { }, + handleDeleteConversation: () => { }, conversationRenaming: false, - handleRenameConversation: () => {}, - handleNewConversationCompleted: () => {}, + handleRenameConversation: () => { }, + handleNewConversationCompleted: () => { }, chatShouldReloadKey: '', isMobile: false, isInstalledApp: false, - handleFeedback: () => {}, - currentChatInstanceRef: { current: { handleStop: () => {} } }, + handleFeedback: () => { }, + currentChatInstanceRef: { current: { handleStop: () => { } } }, }) export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext) 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 64dbb13acf..74241452ef 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -42,6 +42,7 @@ import { changeLanguage } from '@/i18n/i18next-config' import { useAppFavicon } from '@/hooks/use-app-favicon' import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' +import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] @@ -72,6 +73,8 @@ function getFormattedChatList(messages: any[]) { export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) 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 }) + const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId: installedAppInfo?.app.id || appInfo?.app_id, isInstalledApp }) useAppFavicon({ enable: !installedAppInfo, @@ -418,7 +421,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { return { appInfoError, - appInfoLoading, + appInfoLoading: appInfoLoading || isGettingAccessMode || isCheckingPermission, + accessMode: appAccessMode?.accessMode, + userCanAccess: userCanAccessResult?.result, isInstalledApp, appId, currentConversationId, 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 7282dd4216..d9be5cf066 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -27,6 +27,7 @@ const ChatWithHistory: FC = ({ className, }) => { const { + userCanAccess, appInfoError, appData, appInfoLoading, @@ -57,6 +58,8 @@ const ChatWithHistory: FC = ({ ) } + if (!userCanAccess) + return if (appInfoError) { return ( @@ -114,6 +117,8 @@ const ChatWithHistoryWrap: FC = ({ const { appInfoError, appInfoLoading, + accessMode, + userCanAccess, appData, appParams, appMeta, @@ -149,6 +154,8 @@ const ChatWithHistoryWrap: FC = ({ appInfoError, appInfoLoading, appData, + accessMode, + userCanAccess, appParams, appMeta, appChatListDataLoading, 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 69716f9ed8..3709920595 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 @@ -11,10 +11,14 @@ import { Edit05 } from '@/app/components/base/icons/src/vender/line/general' import type { ConversationItem } from '@/models/share' import Confirm from '@/app/components/base/confirm' import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal' +import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown' +import { AccessMode } from '@/models/access-control' const Sidebar = () => { const { t } = useTranslation() const { + isInstalledApp, + accessMode, appData, pinnedConversationList, conversationList, @@ -115,11 +119,14 @@ const Sidebar = () => { ) }

- {appData?.site.copyright && ( -
- © {(new Date()).getFullYear()} {appData?.site.copyright} -
- )} +
+ + {appData?.site.copyright && ( +
+ © {(new Date()).getFullYear()} {appData?.site.copyright} +
+ )} +
{!!showConfirm && ( ({ + userCanAccess: false, + accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, currentConversationId: '', appPrevChatList: [], pinnedConversationList: [], @@ -53,16 +58,16 @@ export const EmbeddedChatbotContext = createContext showConfigPanelBeforeChat: false, newConversationInputs: {}, newConversationInputsRef: { current: {} }, - handleNewConversationInputsChange: () => {}, + handleNewConversationInputsChange: () => { }, inputsForms: [], - handleNewConversation: () => {}, - handleStartChat: () => {}, - handleChangeConversation: () => {}, - handleNewConversationCompleted: () => {}, + handleNewConversation: () => { }, + handleStartChat: () => { }, + handleChangeConversation: () => { }, + handleNewConversationCompleted: () => { }, chatShouldReloadKey: '', isMobile: false, isInstalledApp: false, - handleFeedback: () => {}, - currentChatInstanceRef: { current: { handleStop: () => {} } }, + handleFeedback: () => { }, + currentChatInstanceRef: { current: { handleStop: () => { } } }, }) export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext) diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 077eefc325..a6c3a02a58 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -35,6 +35,7 @@ import { changeLanguage } from '@/i18n/i18next-config' import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' +import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] @@ -65,6 +66,8 @@ function getFormattedChatList(messages: any[]) { export const useEmbeddedChatbot = () => { const isInstalledApp = false const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo) + const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ appId: appInfo?.app_id, isInstalledApp }) + const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp }) const appData = useMemo(() => { return appInfo @@ -319,7 +322,9 @@ export const useEmbeddedChatbot = () => { return { appInfoError, - appInfoLoading, + appInfoLoading: appInfoLoading || isGettingAccessMode || isCheckingPermission, + accessMode: appAccessMode?.accessMode, + userCanAccess: userCanAccessResult?.result, isInstalledApp, appId, currentConversationId, diff --git a/web/app/components/base/chat/embedded-chatbot/index.tsx b/web/app/components/base/chat/embedded-chatbot/index.tsx index 703dde1076..201f38a7f1 100644 --- a/web/app/components/base/chat/embedded-chatbot/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/index.tsx @@ -26,6 +26,7 @@ import Tooltip from '@/app/components/base/tooltip' const Chatbot = () => { const { t } = useTranslation() const { + userCanAccess, isMobile, appInfoError, appInfoLoading, @@ -59,6 +60,9 @@ const Chatbot = () => { ) } + if (!userCanAccess) + return + if (appInfoError) { return ( @@ -91,7 +95,7 @@ const Chatbot = () => { popupContent={t('share.chat.resetChat')} >
- +
@@ -114,6 +118,8 @@ const EmbeddedChatbotWrapper = () => { appInfoError, appInfoLoading, appData, + accessMode, + userCanAccess, appParams, appMeta, appChatListDataLoading, @@ -139,6 +145,8 @@ const EmbeddedChatbotWrapper = () => { } = useEmbeddedChatbot() return = ({ }} onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)} asChild={asChild} + className={!asChild ? triggerClassName : ''} > {children ||
} diff --git a/web/app/components/datasets/create/step-one/index.tsx b/web/app/components/datasets/create/step-one/index.tsx index 2cca003b39..c189649a9f 100644 --- a/web/app/components/datasets/create/step-one/index.tsx +++ b/web/app/components/datasets/create/step-one/index.tsx @@ -18,12 +18,12 @@ import { NotionPageSelector } from '@/app/components/base/notion-page-selector' import { useDatasetDetailContext } from '@/context/dataset-detail' import { useProviderContext } from '@/context/provider-context' import VectorSpaceFull from '@/app/components/billing/vector-space-full' -import classNames from '@/utils/classnames' +import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others' type IStepOneProps = { datasetId?: string dataSourceType?: DataSourceType - dataSourceTypeDisable: Boolean + dataSourceTypeDisable: boolean hasConnection: boolean onSetting: () => void files: FileItem[] @@ -44,14 +44,20 @@ type IStepOneProps = { type NotionConnectorProps = { onSetting: () => void } -export const NotionConnector = ({ onSetting }: NotionConnectorProps) => { +export const NotionConnector = (props: NotionConnectorProps) => { + const { onSetting } = props const { t } = useTranslation() return ( -
- -
{t('datasetCreation.stepOne.notionSyncTitle')}
-
{t('datasetCreation.stepOne.notionSyncTip')}
+
+ +
+ + {t('datasetCreation.stepOne.notionSyncTitle')} + + +
{t('datasetCreation.stepOne.notionSyncTip')}
+
) @@ -120,175 +126,195 @@ const StepOne = ({ return true if (files.some(file => !file.file.id)) return true - if (isShowVectorSpaceFull) - return true - return false + return isShowVectorSpaceFull }, [files, isShowVectorSpaceFull]) return ( -
-
-
-
- { - shouldShowDataSourceTypeList && ( -
{t('datasetCreation.steps.one')}
- ) - } - { - shouldShowDataSourceTypeList && ( -
-
{ - if (dataSourceTypeDisable) - return - changeType(DataSourceType.FILE) - hideFilePreview() - hideNotionPagePreview() - }} - > - - {t('datasetCreation.stepOne.dataSourceType.file')} +
+
+
+
+
+ { + shouldShowDataSourceTypeList && ( +
+ {t('datasetCreation.steps.one')}
-
{ - if (dataSourceTypeDisable) - return - changeType(DataSourceType.NOTION) - hideFilePreview() - hideNotionPagePreview() - }} - > - - {t('datasetCreation.stepOne.dataSourceType.notion')} -
-
changeType(DataSourceType.WEB)} - > - - {t('datasetCreation.stepOne.dataSourceType.web')} -
-
- ) - } - {dataSourceType === DataSourceType.FILE && ( - <> - - {isShowVectorSpaceFull && ( -
- -
- )} -
- {/* */} - -
- - )} - {dataSourceType === DataSourceType.NOTION && ( - <> - {!hasConnection && } - {hasConnection && ( - <> -
- page.page_id)} - onSelect={updateNotionPages} - onPreview={updateCurrentPage} - /> + ) + } + { + shouldShowDataSourceTypeList && ( +
+
{ + if (dataSourceTypeDisable) + return + changeType(DataSourceType.FILE) + hideFilePreview() + hideNotionPagePreview() + }} + > + + + {t('datasetCreation.stepOne.dataSourceType.file')} +
- {isShowVectorSpaceFull && ( -
- -
- )} -
- {/* */} - +
{ + if (dataSourceTypeDisable) + return + changeType(DataSourceType.NOTION) + hideFilePreview() + hideNotionPagePreview() + }} + > + + + {t('datasetCreation.stepOne.dataSourceType.notion')} +
- - )} - - )} - {dataSourceType === DataSourceType.WEB && ( - <> -
- changeType(DataSourceType.WEB)} + > + + + {t('datasetCreation.stepOne.dataSourceType.web')} + +
+
+ ) + } + {dataSourceType === DataSourceType.FILE && ( + <> + -
- {isShowVectorSpaceFull && ( -
- + {isShowVectorSpaceFull && ( +
+ +
+ )} +
+ {/* */} +
- )} -
- {/* */} - -
- - )} - {!datasetId && ( - <> -
- - - {t('datasetCreation.stepOne.emptyDatasetCreation')} - - - )} + + )} + {dataSourceType === DataSourceType.NOTION && ( + <> + {!hasConnection && } + {hasConnection && ( + <> +
+ page.page_id)} + onSelect={updateNotionPages} + onPreview={updateCurrentPage} + /> +
+ {isShowVectorSpaceFull && ( +
+ +
+ )} +
+ {/* */} + +
+ + )} + + )} + {dataSourceType === DataSourceType.WEB && ( + <> +
+ +
+ {isShowVectorSpaceFull && ( +
+ +
+ )} +
+ {/* */} + +
+ + )} + {!datasetId && ( + <> +
+ + + {t('datasetCreation.stepOne.emptyDatasetCreation')} + + + )} +
+
-
-
-
- {currentFile && } - {currentNotionPage && } - {currentWebsite && } +
+ {currentFile && } + {currentNotionPage && } + {currentWebsite && } +
) diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index 2785fcc4bc..7c5edbfe59 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -26,15 +26,15 @@ const InstalledApp: FC = ({ } return ( -
+
{installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && ( )} {installedApp.app.mode === 'completion' && ( - + )} {installedApp.app.mode === 'workflow' && ( - + )}
) diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index c54601aa7c..ec6ca299c3 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -11,9 +11,11 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation' import TabHeader from '../../base/tab-header' import Button from '../../base/button' import { checkOrSetAccessToken } from '../utils' +import AppUnavailable from '../../base/app-unavailable' import s from './style.module.css' import RunBatch from './run-batch' import ResDownload from './run-batch/res-download' +import MenuDropdown from './menu-dropdown' import cn from '@/utils/classnames' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import RunOnce from '@/app/components/share/text-generation/run-once' @@ -37,6 +39,8 @@ import Toast from '@/app/components/base/toast' import type { VisionFile, VisionSettings } from '@/types/app' import { Resolution, TransferMethod } from '@/types/app' import { useAppFavicon } from '@/hooks/use-app-favicon' +import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' +import { AccessMode } from '@/models/access-control' const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group. enum TaskStatus { @@ -106,6 +110,9 @@ const TextGeneration: FC = ({ const [moreLikeThisConfig, setMoreLikeThisConfig] = useState(null) const [textToSpeechConfig, setTextToSpeechConfig] = useState(null) + const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ appId, isInstalledApp }) + const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId, isInstalledApp }) + // save message const [savedMessages, setSavedMessages] = useState([]) const fetchSavedMessage = async () => { @@ -537,12 +544,14 @@ const TextGeneration: FC = ({
) - if (!appId || !siteInfo || !promptConfig) { + if (!appId || !siteInfo || !promptConfig || isGettingAccessMode || isCheckingPermission) { return (
) } + if (!userCanAccessResult?.result) + return return ( <> @@ -558,16 +567,19 @@ const TextGeneration: FC = ({ 'shrink-0 relative flex flex-col pb-10 h-full border-r border-gray-100 bg-white', )}>
-
-
- -
{siteInfo.title}
+
+
+
+ +
{siteInfo.title}
+
+
{!isPC && (