diff --git a/web/features/home/continue-work/__tests__/item.spec.tsx b/web/features/home/continue-work/__tests__/item.spec.tsx
index ee61d10d6ce..37b74a4b944 100644
--- a/web/features/home/continue-work/__tests__/item.spec.tsx
+++ b/web/features/home/continue-work/__tests__/item.spec.tsx
@@ -168,10 +168,15 @@ describe('ContinueWorkItem', () => {
)
})
- it('should fall back to access point when RBAC is disabled for an access-config-only app', () => {
- renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), {
- rbac_enabled: false,
- })
+ it('should fall back to access point when RBAC is disabled for an access-config app with access point permission', () => {
+ renderItem(
+ createApp({
+ permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPoint],
+ }),
+ {
+ rbac_enabled: false,
+ },
+ )
expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute(
'href',
diff --git a/web/features/skills/page.tsx b/web/features/skills/page.tsx
index 4a95adb6783..6c3ba9a71a9 100644
--- a/web/features/skills/page.tsx
+++ b/web/features/skills/page.tsx
@@ -464,8 +464,7 @@ function SkillCard({
{(canEdit || canDelete || !!skill.latest_published_version_id) && (
diff --git a/web/features/tag-management/__tests__/tag-selector.spec.tsx b/web/features/tag-management/__tests__/tag-selector.spec.tsx
index 5ef445420f8..2cb24e804f9 100644
--- a/web/features/tag-management/__tests__/tag-selector.spec.tsx
+++ b/web/features/tag-management/__tests__/tag-selector.spec.tsx
@@ -150,6 +150,12 @@ describe('TagSelector', () => {
expect(screen.getByText('Frontend')).toBeInTheDocument()
})
+ it('adds the owning app name to the combobox accessible name', () => {
+ render()
+
+ expect(screen.getByRole('combobox', { name: 'Frontend: Test App' })).toBeInTheDocument()
+ })
+
it('keeps dataset tag interactions inside the tag trigger', async () => {
const user = userEvent.setup()
const onOuterClick = vi.fn()
diff --git a/web/features/tag-management/components/app-card-tags.tsx b/web/features/tag-management/components/app-card-tags.tsx
index eeaeb0e4e89..d5400e2c8f0 100644
--- a/web/features/tag-management/components/app-card-tags.tsx
+++ b/web/features/tag-management/components/app-card-tags.tsx
@@ -3,6 +3,7 @@ import { TagSelector } from '@/features/tag-management/components/tag-selector'
type AppCardTagsProps = {
appId: string
+ appName: string
tags: Tag[]
canBindOrUnbindTags?: boolean
onOpenTagManagement?: () => void
@@ -11,6 +12,7 @@ type AppCardTagsProps = {
export const AppCardTags = ({
appId,
+ appName,
tags,
canBindOrUnbindTags,
onOpenTagManagement = () => {},
@@ -20,6 +22,7 @@ export const AppCardTags = ({
& {
targetId: string
+ contextLabel?: string
type: TagType
value: Tag[]
canBindOrUnbindTags?: boolean
@@ -84,6 +85,7 @@ export type TagSelectorProps = TagSelectorRootProps &
export const TagSelector = ({
targetId,
+ contextLabel,
type,
value,
canBindOrUnbindTags,
@@ -141,6 +143,7 @@ export const TagSelector = ({
? t(($) => $['tag.addTag'], { ns: 'common' })
: t(($) => $['tag.noTag'], { ns: 'common' })
const triggerLabel = tagNames.length ? tagNames.join(', ') : emptyTriggerLabel
+ const accessibleTriggerLabel = contextLabel ? `${triggerLabel}: ${contextLabel}` : triggerLabel
const items = useMemo(() => {
const tagIds = new Set()
@@ -300,7 +303,7 @@ export const TagSelector = ({
>
{
if (titleStr !== null) document.title = titleStr
}, [titleStr])
- useEffect(() => {
- let apple: HTMLLinkElement | null = null
- if (branding.favicon) {
- document
- .querySelectorAll(
- "link[rel='icon'], link[rel='shortcut icon'], link[rel='apple-touch-icon'], link[rel='mask-icon']",
- )
- .forEach((n) => n.parentNode?.removeChild(n))
-
- apple = document.createElement('link')
- apple.rel = 'apple-touch-icon'
- apple.href = branding.favicon
- document.head.appendChild(apple)
- }
-
- return () => {
- apple?.remove()
- }
- }, [branding.favicon])
- useFavicon(favicon)
}
diff --git a/web/hooks/use-import-dsl.spec.tsx b/web/hooks/use-import-dsl.spec.tsx
index 1f6e95205f9..d8aff462318 100644
--- a/web/hooks/use-import-dsl.spec.tsx
+++ b/web/hooks/use-import-dsl.spec.tsx
@@ -1,4 +1,4 @@
-import { act, waitFor } from '@testing-library/react'
+import { act, render, screen, waitFor } from '@testing-library/react'
import { DSLImportMode, DSLImportStatus } from '@/models/app'
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
@@ -93,6 +93,46 @@ describe('useImportDSL', () => {
mockResolveImportedAppRedirectionTarget.mockImplementation(async (target) => target)
})
+ it('should show response warnings when an import completes with warnings', async () => {
+ const completedResponse = {
+ id: 'import-1',
+ status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
+ app_id: 'app-1',
+ app_mode: AppModeEnum.WORKFLOW,
+ permission_keys: [],
+ warnings: [
+ {
+ code: 'agent_tool_authorization_required',
+ path: 'agent_packages.agent_1.soul.tools.dify_tools.0',
+ message: "Agent tool 'jina_search' requires authorization.",
+ details: { tool_name: 'jina_search' },
+ },
+ ],
+ }
+ mockImportDSL.mockResolvedValue(completedResponse)
+ mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
+
+ const { result } = renderHookWithConsoleQuery(() => useImportDSL())
+
+ await act(async () => {
+ await result.current.handleImportDSL(
+ {
+ mode: DSLImportMode.YAML_CONTENT,
+ yaml_content: 'app: demo',
+ },
+ { skipRedirectOnSuccess: true },
+ )
+ })
+
+ expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', {
+ description: expect.anything(),
+ })
+ const warningDescription = toastMocks.warning.mock.calls[0]![1].description
+ render(<>{warningDescription}>)
+ expect(screen.getByText("Agent tool 'jina_search' requires authorization.")).toBeInTheDocument()
+ expect(screen.queryByText('app.newApp.appCreateDSLWarning')).not.toBeInTheDocument()
+ })
+
it('should complete a confirmed import that returns warnings', async () => {
let resolvePluginCheck: (() => void) | undefined
const pendingResponse = {
@@ -163,8 +203,12 @@ describe('useImportDSL', () => {
expect(onSuccess).toHaveBeenCalledWith(completedResponse)
expect(onFailed).not.toHaveBeenCalled()
expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', {
- description: 'app.newApp.appCreateDSLWarning',
+ description: expect.anything(),
})
+ const warningDescription = toastMocks.warning.mock.calls[0]![1].description
+ render(<>{warningDescription}>)
+ expect(screen.getByText('Agent file was not included.')).toBeInTheDocument()
+ expect(screen.queryByText('app.newApp.appCreateDSLWarning')).not.toBeInTheDocument()
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({
id: 'app-1',
diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts
index dda857efdb5..81e32d46a91 100644
--- a/web/hooks/use-import-dsl.ts
+++ b/web/hooks/use-import-dsl.ts
@@ -3,8 +3,9 @@ import type { AppIconType } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
-import { useCallback, useRef, useState } from 'react'
+import { createElement, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import DSLImportWarningDescription from '@/app/components/app/create-from-dsl-modal/dsl-import-warning-description'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
@@ -80,7 +81,10 @@ export const useImportDSL = () => {
)
const description =
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
- ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
+ ? createElement(DSLImportWarningDescription, {
+ warnings: response.warnings,
+ fallback: t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }),
+ })
: undefined
if (status === DSLImportStatus.COMPLETED) toast.success(message)
@@ -162,7 +166,10 @@ export const useImportDSL = () => {
)
const description =
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
- ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
+ ? createElement(DSLImportWarningDescription, {
+ warnings: response.warnings,
+ fallback: t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }),
+ })
: undefined
if (status === DSLImportStatus.COMPLETED) toast.success(message)
diff --git a/web/i18n/ar-TN/permission-keys.json b/web/i18n/ar-TN/permission-keys.json
index a7f129455c5..18321e3b376 100644
--- a/web/i18n/ar-TN/permission-keys.json
+++ b/web/i18n/ar-TN/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "إدارة إعدادات امتداد API",
"app.access_config": "تكوين أذونات الوصول إلى التطبيق",
"app.acl.access_config": "عرض أذونات الوصول وإدارتها",
+ "app.acl.access_point_manage": "عرض نقاط الوصول وإدارتها",
"app.acl.delete": "حذف التطبيق",
"app.acl.deploy": "نشر التطبيق",
"app.acl.edit": "تعديل معلومات التطبيق وتنسيقه",
diff --git a/web/i18n/de-DE/permission-keys.json b/web/i18n/de-DE/permission-keys.json
index fb32d92c699..2d546c0e082 100644
--- a/web/i18n/de-DE/permission-keys.json
+++ b/web/i18n/de-DE/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API-Erweiterungskonfiguration verwalten",
"app.access_config": "App-Zugriffsberechtigungen konfigurieren",
"app.acl.access_config": "Zugriffsberechtigungen anzeigen und verwalten",
+ "app.acl.access_point_manage": "Zugangspunkte anzeigen und verwalten",
"app.acl.delete": "App löschen",
"app.acl.deploy": "App bereitstellen",
"app.acl.edit": "App-Informationen bearbeiten und App orchestrieren",
diff --git a/web/i18n/en-US/permission-keys.json b/web/i18n/en-US/permission-keys.json
index 2344caa11e1..e69f68bb0b3 100644
--- a/web/i18n/en-US/permission-keys.json
+++ b/web/i18n/en-US/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Manage API extension configuration",
"app.access_config": "Configure app access permissions",
"app.acl.access_config": "View and manage access permissions",
+ "app.acl.access_point_manage": "View and manage access points",
"app.acl.delete": "Delete app",
"app.acl.deploy": "Deploy app",
"app.acl.edit": "Edit app information and orchestrate app",
diff --git a/web/i18n/es-ES/permission-keys.json b/web/i18n/es-ES/permission-keys.json
index af3336ceea7..d7d5e9d57c8 100644
--- a/web/i18n/es-ES/permission-keys.json
+++ b/web/i18n/es-ES/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gestionar la configuración de la extensión de API",
"app.access_config": "Configurar los permisos de acceso de la app",
"app.acl.access_config": "Ver y gestionar los permisos de acceso",
+ "app.acl.access_point_manage": "Ver y gestionar los puntos de acceso",
"app.acl.delete": "Eliminar app",
"app.acl.deploy": "Desplegar la app",
"app.acl.edit": "Editar la información y orquestar la app",
diff --git a/web/i18n/fa-IR/permission-keys.json b/web/i18n/fa-IR/permission-keys.json
index 372374ed3b8..5e739bed336 100644
--- a/web/i18n/fa-IR/permission-keys.json
+++ b/web/i18n/fa-IR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "مدیریت پیکربندی افزونه API",
"app.access_config": "پیکربندی مجوزهای دسترسی برنامه",
"app.acl.access_config": "مشاهده و مدیریت مجوزهای دسترسی",
+ "app.acl.access_point_manage": "مشاهده و مدیریت نقاط دسترسی",
"app.acl.delete": "حذف برنامه",
"app.acl.deploy": "استقرار برنامه",
"app.acl.edit": "ویرایش اطلاعات برنامه و هماهنگسازی برنامه",
diff --git a/web/i18n/fr-FR/permission-keys.json b/web/i18n/fr-FR/permission-keys.json
index 074da133fae..c547052586d 100644
--- a/web/i18n/fr-FR/permission-keys.json
+++ b/web/i18n/fr-FR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gérer la configuration de l'extension API",
"app.access_config": "Configurer les autorisations d'accès à l'application",
"app.acl.access_config": "Afficher et gérer les autorisations d'accès",
+ "app.acl.access_point_manage": "Afficher et gérer les points d’accès",
"app.acl.delete": "Supprimer l'application",
"app.acl.deploy": "Déployer l'application",
"app.acl.edit": "Modifier les informations et orchestrer l'application",
diff --git a/web/i18n/hi-IN/permission-keys.json b/web/i18n/hi-IN/permission-keys.json
index 314cbfba84b..0779d870342 100644
--- a/web/i18n/hi-IN/permission-keys.json
+++ b/web/i18n/hi-IN/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API एक्सटेंशन कॉन्फ़िगरेशन प्रबंधित करें",
"app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें",
"app.acl.access_config": "एक्सेस अनुमतियाँ देखें और प्रबंधित करें",
+ "app.acl.access_point_manage": "एक्सेस पॉइंट देखें और प्रबंधित करें",
"app.acl.delete": "ऐप हटाएं",
"app.acl.deploy": "ऐप डिप्लॉय करें",
"app.acl.edit": "ऐप की जानकारी संपादित करें और ऐप को ऑर्केस्ट्रेट करें",
diff --git a/web/i18n/id-ID/permission-keys.json b/web/i18n/id-ID/permission-keys.json
index 4b04ea96f00..349bff75538 100644
--- a/web/i18n/id-ID/permission-keys.json
+++ b/web/i18n/id-ID/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Kelola konfigurasi ekstensi API",
"app.access_config": "Konfigurasikan izin akses aplikasi",
"app.acl.access_config": "Lihat dan kelola izin akses",
+ "app.acl.access_point_manage": "Lihat dan kelola titik akses",
"app.acl.delete": "Hapus aplikasi",
"app.acl.deploy": "Deploy aplikasi",
"app.acl.edit": "Edit informasi aplikasi dan orkestrasikan aplikasi",
diff --git a/web/i18n/it-IT/permission-keys.json b/web/i18n/it-IT/permission-keys.json
index 899adce084b..b5f3ebf8094 100644
--- a/web/i18n/it-IT/permission-keys.json
+++ b/web/i18n/it-IT/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gestisci la configurazione delle estensioni API",
"app.access_config": "Configura i permessi di accesso all'app",
"app.acl.access_config": "Visualizza e gestisci i permessi di accesso",
+ "app.acl.access_point_manage": "Visualizza e gestisci i punti di accesso",
"app.acl.delete": "Elimina app",
"app.acl.deploy": "Distribuisci app",
"app.acl.edit": "Modifica le informazioni e orchestra l'app",
diff --git a/web/i18n/ja-JP/permission-keys.json b/web/i18n/ja-JP/permission-keys.json
index 1b0c567f0e8..53033e2dc10 100644
--- a/web/i18n/ja-JP/permission-keys.json
+++ b/web/i18n/ja-JP/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API拡張設定を管理",
"app.access_config": "アプリアクセス権限を設定",
"app.acl.access_config": "アクセス権限の表示と管理",
+ "app.acl.access_point_manage": "アクセスポイントの表示と管理",
"app.acl.delete": "アプリを削除",
"app.acl.deploy": "アプリをデプロイ",
"app.acl.edit": "アプリ情報の編集とアプリのオーケストレーション",
diff --git a/web/i18n/ko-KR/permission-keys.json b/web/i18n/ko-KR/permission-keys.json
index 3a9981a602a..45517f6acb9 100644
--- a/web/i18n/ko-KR/permission-keys.json
+++ b/web/i18n/ko-KR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API 확장 구성 관리",
"app.access_config": "앱 접근 권한 구성",
"app.acl.access_config": "접근 권한 보기 및 관리",
+ "app.acl.access_point_manage": "액세스 지점 보기 및 관리",
"app.acl.delete": "앱 삭제",
"app.acl.deploy": "앱 배포",
"app.acl.edit": "앱 정보 편집 및 앱 오케스트레이션",
diff --git a/web/i18n/lo-LA/permission-keys.json b/web/i18n/lo-LA/permission-keys.json
index bfba51cc7e2..e04ef1f59ec 100644
--- a/web/i18n/lo-LA/permission-keys.json
+++ b/web/i18n/lo-LA/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "ຈັດການການຕັ້ງຄ່າ API extension",
"app.access_config": "ຕັ້ງຄ່າສິດການເຂົ້າເຖິງແອັບ",
"app.acl.access_config": "ເບິ່ງ ແລະ ຈັດການສິດການເຂົ້າເຖິງ",
+ "app.acl.access_point_manage": "ເບິ່ງ ແລະ ຈັດການຈຸດເຂົ້າເຖິງ",
"app.acl.delete": "ລຶບແອັບ",
"app.acl.deploy": "ຕິດຕັ້ງແອັບ",
"app.acl.edit": "ແກ້ໄຂຂໍ້ມູນແອັບ ແລະ ຈັດການລະບົບແອັບ",
diff --git a/web/i18n/nl-NL/permission-keys.json b/web/i18n/nl-NL/permission-keys.json
index 94fdf9d182c..8266b38ae22 100644
--- a/web/i18n/nl-NL/permission-keys.json
+++ b/web/i18n/nl-NL/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API-extensieconfiguratie beheren",
"app.access_config": "Toegangsrechten voor app configureren",
"app.acl.access_config": "Toegangsrechten bekijken en beheren",
+ "app.acl.access_point_manage": "Toegangspunten bekijken en beheren",
"app.acl.delete": "App verwijderen",
"app.acl.deploy": "App implementeren",
"app.acl.edit": "App-informatie bewerken en app orkestreren",
diff --git a/web/i18n/pl-PL/permission-keys.json b/web/i18n/pl-PL/permission-keys.json
index 55619735f16..392f470914e 100644
--- a/web/i18n/pl-PL/permission-keys.json
+++ b/web/i18n/pl-PL/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Zarządzaj konfiguracją rozszerzenia API",
"app.access_config": "Konfiguruj uprawnienia dostępu do aplikacji",
"app.acl.access_config": "Wyświetlaj uprawnienia dostępu i zarządzaj nimi",
+ "app.acl.access_point_manage": "Wyświetlaj punkty dostępu i zarządzaj nimi",
"app.acl.delete": "Usuń aplikację",
"app.acl.deploy": "Wdróż aplikację",
"app.acl.edit": "Edytuj informacje o aplikacji i orkiestruj aplikację",
diff --git a/web/i18n/pt-BR/permission-keys.json b/web/i18n/pt-BR/permission-keys.json
index 32dda95b517..36dd03d5719 100644
--- a/web/i18n/pt-BR/permission-keys.json
+++ b/web/i18n/pt-BR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gerenciar configuração de extensão de API",
"app.access_config": "Configurar permissões de acesso ao aplicativo",
"app.acl.access_config": "Visualizar e gerenciar permissões de acesso",
+ "app.acl.access_point_manage": "Visualizar e gerenciar pontos de acesso",
"app.acl.delete": "Excluir aplicativo",
"app.acl.deploy": "Implantar aplicativo",
"app.acl.edit": "Editar informações e orquestrar o aplicativo",
diff --git a/web/i18n/ro-RO/permission-keys.json b/web/i18n/ro-RO/permission-keys.json
index 2610e185492..73225f7cd60 100644
--- a/web/i18n/ro-RO/permission-keys.json
+++ b/web/i18n/ro-RO/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gestionează configurația extensiei API",
"app.access_config": "Configurează permisiunile de acces ale aplicației",
"app.acl.access_config": "Vizualizează și gestionează permisiunile de acces",
+ "app.acl.access_point_manage": "Vizualizează și gestionează punctele de acces",
"app.acl.delete": "Șterge aplicația",
"app.acl.deploy": "Implementează aplicația",
"app.acl.edit": "Editează informațiile aplicației și orchestrează aplicația",
diff --git a/web/i18n/ru-RU/permission-keys.json b/web/i18n/ru-RU/permission-keys.json
index 574f0e96add..bd986d65e95 100644
--- a/web/i18n/ru-RU/permission-keys.json
+++ b/web/i18n/ru-RU/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Управление конфигурацией API-расширений",
"app.access_config": "Настройка прав доступа к приложению",
"app.acl.access_config": "Просмотр и управление правами доступа",
+ "app.acl.access_point_manage": "Просмотр и управление точками доступа",
"app.acl.delete": "Удаление приложения",
"app.acl.deploy": "Развертывание приложения",
"app.acl.edit": "Редактирование информации о приложении и оркестрация приложения",
diff --git a/web/i18n/sl-SI/permission-keys.json b/web/i18n/sl-SI/permission-keys.json
index 544c6f92a8d..4a49494a2c2 100644
--- a/web/i18n/sl-SI/permission-keys.json
+++ b/web/i18n/sl-SI/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Upravljanje konfiguracije razširitve API",
"app.access_config": "Konfiguracija dovoljenj za dostop do aplikacije",
"app.acl.access_config": "Ogled in upravljanje dovoljenj za dostop",
+ "app.acl.access_point_manage": "Ogled in upravljanje dostopnih točk",
"app.acl.delete": "Izbriši aplikacijo",
"app.acl.deploy": "Uvedi aplikacijo",
"app.acl.edit": "Uredi podatke o aplikaciji in orkestriraj aplikacijo",
diff --git a/web/i18n/th-TH/permission-keys.json b/web/i18n/th-TH/permission-keys.json
index b7b9854abf0..0ad4047ef26 100644
--- a/web/i18n/th-TH/permission-keys.json
+++ b/web/i18n/th-TH/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "จัดการการกําหนดค่าส่วนขยาย API",
"app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป",
"app.acl.access_config": "ดูและจัดการสิทธิ์การเข้าถึง",
+ "app.acl.access_point_manage": "ดูและจัดการจุดเข้าถึง",
"app.acl.delete": "ลบแอป",
"app.acl.deploy": "ปรับใช้แอป",
"app.acl.edit": "แก้ไขข้อมูลแอปและจัดวางแอป",
diff --git a/web/i18n/tr-TR/permission-keys.json b/web/i18n/tr-TR/permission-keys.json
index 36ba8ec9709..781d9d00d38 100644
--- a/web/i18n/tr-TR/permission-keys.json
+++ b/web/i18n/tr-TR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API uzantısı yapılandırmasını yönet",
"app.access_config": "Uygulama erişim izinlerini yapılandır",
"app.acl.access_config": "Erişim izinlerini görüntüle ve yönet",
+ "app.acl.access_point_manage": "Erişim noktalarını görüntüle ve yönet",
"app.acl.delete": "Uygulamayı sil",
"app.acl.deploy": "Uygulamayı dağıt",
"app.acl.edit": "Uygulama bilgilerini düzenle ve uygulamayı orkestre et",
diff --git a/web/i18n/uk-UA/permission-keys.json b/web/i18n/uk-UA/permission-keys.json
index 861c83a4367..8cfd28d2548 100644
--- a/web/i18n/uk-UA/permission-keys.json
+++ b/web/i18n/uk-UA/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Керування конфігурацією розширення API",
"app.access_config": "Налаштування дозволів доступу до застосунку",
"app.acl.access_config": "Переглядати дозволи доступу та керувати ними",
+ "app.acl.access_point_manage": "Переглядати точки доступу та керувати ними",
"app.acl.delete": "Видалити застосунок",
"app.acl.deploy": "Розгорнути застосунок",
"app.acl.edit": "Редагувати інформацію про застосунок та оркеструвати застосунок",
diff --git a/web/i18n/vi-VN/permission-keys.json b/web/i18n/vi-VN/permission-keys.json
index 1e6662a9304..2290d6362e0 100644
--- a/web/i18n/vi-VN/permission-keys.json
+++ b/web/i18n/vi-VN/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Quản lý cấu hình phần mở rộng API",
"app.access_config": "Cấu hình quyền truy cập ứng dụng",
"app.acl.access_config": "Xem và quản lý quyền truy cập",
+ "app.acl.access_point_manage": "Xem và quản lý điểm truy cập",
"app.acl.delete": "Xóa ứng dụng",
"app.acl.deploy": "Triển khai ứng dụng",
"app.acl.edit": "Chỉnh sửa thông tin và điều phối ứng dụng",
diff --git a/web/i18n/zh-Hans/permission-keys.json b/web/i18n/zh-Hans/permission-keys.json
index 91d9afb2cc8..db8184ee533 100644
--- a/web/i18n/zh-Hans/permission-keys.json
+++ b/web/i18n/zh-Hans/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "管理API扩展",
"app.access_config": "配置应用访问权限",
"app.acl.access_config": "查看与管理访问权限",
+ "app.acl.access_point_manage": "查看与管理访问点",
"app.acl.delete": "删除应用",
"app.acl.deploy": "部署应用",
"app.acl.edit": "编辑应用信息与编排应用",
diff --git a/web/i18n/zh-Hant/permission-keys.json b/web/i18n/zh-Hant/permission-keys.json
index 43350a59ada..8a74b37f086 100644
--- a/web/i18n/zh-Hant/permission-keys.json
+++ b/web/i18n/zh-Hant/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "管理API擴充配置",
"app.access_config": "配置應用訪問權限",
"app.acl.access_config": "檢視與管理存取權限",
+ "app.acl.access_point_manage": "檢視與管理存取點",
"app.acl.delete": "刪除應用",
"app.acl.deploy": "部署應用",
"app.acl.edit": "編輯應用資訊與編排應用",
diff --git a/web/app/favicon.ico b/web/public/favicon.ico
similarity index 100%
rename from web/app/favicon.ico
rename to web/public/favicon.ico
diff --git a/web/service/__tests__/base-request.spec.ts b/web/service/__tests__/base-request.spec.ts
index c324a83cb55..0da529bc10c 100644
--- a/web/service/__tests__/base-request.spec.ts
+++ b/web/service/__tests__/base-request.spec.ts
@@ -1,4 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import {
+ discardRegistrationSessionState,
+ OAUTH_REGISTRATION_GA_SENT_KEY,
+ REGISTRATION_SUCCESS_STORAGE_KEY,
+} from '@/app/components/base/amplitude/registration-session-state'
// oxlint-disable-next-line no-restricted-imports -- This spec directly tests the legacy request owner.
import { request } from '../base'
@@ -51,6 +56,21 @@ const createUnauthorizedResponse = () =>
},
)
+const createForcedLogoutResponse = () =>
+ new Response(
+ JSON.stringify({
+ code: 'unauthorized_and_force_logout',
+ message: 'This account session is no longer valid.',
+ status: 401,
+ }),
+ {
+ status: 401,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ )
+
type ClientRequestOptions = {
response: Response
refreshError?: Error
@@ -80,9 +100,11 @@ describe('request 401 handling', () => {
writable: true,
configurable: true,
})
+ window.sessionStorage.clear()
})
afterEach(() => {
+ discardRegistrationSessionState()
Object.defineProperty(globalThis, 'location', {
value: originalLocation,
writable: true,
@@ -103,21 +125,42 @@ describe('request 401 handling', () => {
it('should preserve the current URL when a 401 response cannot be parsed', async () => {
const response = new Response('not-json', { status: 401 })
arrangeClientRequest({ response })
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker')
await expect(request('/account/profile')).rejects.toBe(response)
expect(globalThis.location.href).toBe(
`https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`,
)
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
expect(mocks.refreshAccessTokenOrReLogin).not.toHaveBeenCalled()
})
+ it('clears account A registration state before a forced reload so account B starts clean', async () => {
+ const response = createForcedLogoutResponse()
+ arrangeClientRequest({ response })
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker')
+ window.sessionStorage.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true')
+ const reload = vi.fn(() => {
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
+ expect(window.sessionStorage.getItem(OAUTH_REGISTRATION_GA_SENT_KEY)).toBeNull()
+ })
+ globalThis.location.reload = reload
+
+ await expect(request('/account/profile')).rejects.toBe(response)
+
+ expect(reload).toHaveBeenCalledOnce()
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-b-marker')
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe('account-b-marker')
+ })
+
it('should preserve the current URL when token refresh fails', async () => {
const response = createUnauthorizedResponse()
arrangeClientRequest({
response,
refreshError: new Error('refresh failed'),
})
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker')
await expect(request('/account/profile')).rejects.toBe(response)
@@ -125,5 +168,16 @@ describe('request 401 handling', () => {
expect(globalThis.location.href).toBe(
`https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`,
)
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
+ })
+
+ it('does not clear console registration state for a public-app 401 redirect', async () => {
+ const response = createUnauthorizedResponse()
+ arrangeClientRequest({ response })
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'console-marker')
+
+ await expect(request('/account/profile', {}, { isPublicAPI: true })).rejects.toBe(response)
+
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe('console-marker')
})
})
diff --git a/web/service/base.ts b/web/service/base.ts
index d5ece4cd7d4..ea2435fdb6b 100644
--- a/web/service/base.ts
+++ b/web/service/base.ts
@@ -30,6 +30,7 @@ import type {
} from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import Cookies from 'js-cookie'
+import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state'
import {
API_PREFIX,
CSRF_COOKIE_NAME,
@@ -197,6 +198,14 @@ export type IOtherOptions = {
onDataSourceNodeError?: IOnDataSourceNodeError
}
+const discardRegistrationStateForConsoleAuthBoundary = ({
+ isMarketplaceAPI,
+ isPublicAPI,
+}: IOtherOptions) => {
+ if (isMarketplaceAPI || isPublicAPI) return
+ discardRegistrationSessionState()
+}
+
function jumpTo(url: string) {
if (!url || !isClient) return
const targetPath = new URL(url, window.location.origin).pathname
@@ -1008,6 +1017,7 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe
const [parseErr, errRespData] = await asyncRunSafe(errResp.json())
if (parseErr) {
+ discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch)
window.location.href = buildSigninUrlWithRedirect()
return Promise.reject(err)
}
@@ -1025,6 +1035,7 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe
}
if (code === 'unauthorized_and_force_logout') {
// Cookies will be cleared by the backend
+ discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch)
window.location.reload()
return Promise.reject(err)
}
@@ -1053,6 +1064,7 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe
// there. Redirecting to /signin loses the user_code context and
// the post-login flow lands on /apps instead of returning here.
if (window.location.pathname === `${basePath}/device`) return Promise.reject(err)
+ discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch)
if (window.location.pathname !== `${basePath}/signin`) {
jumpTo(buildSigninUrlWithRedirect())
return Promise.reject(err)
diff --git a/web/service/common.spec.ts b/web/service/common.spec.ts
index ef678cb69f9..cfbf09ad331 100644
--- a/web/service/common.spec.ts
+++ b/web/service/common.spec.ts
@@ -1,5 +1,14 @@
+import type { ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, renderHook } from '@testing-library/react'
+import { createElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import {
+ OAUTH_REGISTRATION_GA_SENT_KEY,
+ REGISTRATION_SUCCESS_STORAGE_KEY,
+} from '@/app/components/base/amplitude/registration-session-state'
import { emailLoginWithCode, sendEMailLoginCode } from './common'
+import { useLogout } from './use-common'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
@@ -68,3 +77,29 @@ describe('emailLoginWithCode', () => {
})
})
})
+
+describe('useLogout', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ window.sessionStorage.clear()
+ })
+
+ it('discards registration delivery state after a successful logout', async () => {
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(['account-profile'], { id: 'previous-user' })
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'pending-marker')
+ window.sessionStorage.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true')
+ mocks.post.mockResolvedValueOnce({ result: 'success' })
+ const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client: queryClient }, children)
+ const { result } = renderHook(() => useLogout(), { wrapper })
+
+ await act(async () => {
+ await result.current.mutateAsync()
+ })
+
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
+ expect(window.sessionStorage.getItem(OAUTH_REGISTRATION_GA_SENT_KEY)).toBeNull()
+ expect(queryClient.getQueryData(['account-profile'])).toBeUndefined()
+ })
+})
diff --git a/web/service/use-common.ts b/web/service/use-common.ts
index 95564c5fbea..524a38bfd0f 100644
--- a/web/service/use-common.ts
+++ b/web/service/use-common.ts
@@ -17,6 +17,7 @@ import type {
} from '@/models/common'
import type { RETRIEVE_METHOD } from '@/types/app'
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state'
// oxlint-disable-next-line no-restricted-imports
import { get, post } from './base'
import { consoleQuery } from './client'
@@ -162,6 +163,7 @@ export const useLogout = () => {
mutationKey: [NAME_SPACE, 'logout'],
mutationFn: () => post('/logout'),
onSuccess: () => {
+ discardRegistrationSessionState()
// Drop all cached queries so the post-logout /signin probe doesn't read
// the previous user's profile (the userProfile queryKey is shared with
// the (commonLayout) tree, which keeps observing it during React's
diff --git a/web/utils/app-redirection.spec.ts b/web/utils/app-redirection.spec.ts
index d736ed5a428..521a500652d 100644
--- a/web/utils/app-redirection.spec.ts
+++ b/web/utils/app-redirection.spec.ts
@@ -14,12 +14,22 @@ describe('app-redirection', () => {
* - App mode (workflow, advanced-chat, chat, completion, agent-chat)
*/
describe('getRedirectionPath', () => {
- it('returns access point path when app ACL cannot access guarded pages', () => {
- const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] }
+ it('returns access point path when app access point permission is granted', () => {
+ const app = {
+ id: 'app-123',
+ mode: AppModeEnum.CHAT,
+ permission_keys: [AppACLPermission.AccessPoint],
+ }
const result = getRedirectionPath(app)
expect(result).toBe('/app/app-123/access-point')
})
+ it('returns apps list path when app ACL cannot access guarded pages or access point', () => {
+ const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] }
+ const result = getRedirectionPath(app)
+ expect(result).toBe('/apps')
+ })
+
it('returns workflow path for workflow mode when app ACL can access layout', () => {
const app = {
id: 'app-123',
@@ -92,7 +102,11 @@ describe('app-redirection', () => {
})
it('handles different app IDs', () => {
- const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT, permission_keys: [] }
+ const app1 = {
+ id: 'abc-123',
+ mode: AppModeEnum.CHAT,
+ permission_keys: [AppACLPermission.AccessPoint],
+ }
const app2 = {
id: 'xyz-789',
mode: AppModeEnum.WORKFLOW,
@@ -129,7 +143,7 @@ describe('app-redirection', () => {
const app = {
id: 'app-123',
mode: AppModeEnum.CHAT,
- permission_keys: [AppACLPermission.AccessConfig],
+ permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPoint],
}
expect(getRedirectionPath(app, { isRbacEnabled: false })).toBe('/app/app-123/access-point')
@@ -173,8 +187,12 @@ describe('app-redirection', () => {
/**
* Tests that the redirection function is called with the correct path
*/
- it('calls redirection function with access point path when app ACL cannot access guarded pages', () => {
- const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] }
+ it('calls redirection function with access point path when access point permission is granted', () => {
+ const app = {
+ id: 'app-123',
+ mode: AppModeEnum.CHAT,
+ permission_keys: [AppACLPermission.AccessPoint],
+ }
const mockRedirect = vi.fn()
getRedirection(app, mockRedirect)
diff --git a/web/utils/app-redirection.ts b/web/utils/app-redirection.ts
index 4bdfce9a266..d73a350b3f3 100644
--- a/web/utils/app-redirection.ts
+++ b/web/utils/app-redirection.ts
@@ -34,7 +34,9 @@ export const getRedirectionPath = (
if (app.mode === AppModeEnum.WORKFLOW && appACLCapabilities.canDeploy)
return `/app/${app.id}/deploy`
- return `/app/${app.id}/access-point`
+ if (appACLCapabilities.canAccessPoint) return `/app/${app.id}/access-point`
+
+ return '/apps'
}
export const getRedirection = (
diff --git a/web/utils/permission.spec.ts b/web/utils/permission.spec.ts
index 75dff3e7afe..487c9c891b1 100644
--- a/web/utils/permission.spec.ts
+++ b/web/utils/permission.spec.ts
@@ -50,6 +50,15 @@ describe('permission', () => {
expect(releaseCapabilities.canDeploy).toBe(false)
})
+ it('keeps access point permission independent from other app ACL permissions', () => {
+ const accessPointCapabilities = getAppACLCapabilities([AppACLPermission.AccessPoint])
+ const layoutCapabilities = getAppACLCapabilities([AppACLPermission.ViewLayout])
+
+ expect(accessPointCapabilities.canAccessPoint).toBe(true)
+ expect(accessPointCapabilities.canAccessLayout).toBe(false)
+ expect(layoutCapabilities.canAccessPoint).toBe(false)
+ })
+
it('keeps monitor, tracing config, and log/annotation permissions independent', () => {
const monitorCapabilities = getAppACLCapabilities([AppACLPermission.Monitor])
const tracingCapabilities = getAppACLCapabilities([AppACLPermission.TracingConfig])
@@ -109,6 +118,7 @@ describe('permission', () => {
})
expect(capabilities.canViewLayout).toBe(true)
+ expect(capabilities.canAccessPoint).toBe(true)
expect(capabilities.canTestAndRun).toBe(true)
expect(capabilities.canEdit).toBe(true)
expect(capabilities.canImportExportDSL).toBe(true)
diff --git a/web/utils/permission.ts b/web/utils/permission.ts
index 063878b6607..30b0b3e0cbe 100644
--- a/web/utils/permission.ts
+++ b/web/utils/permission.ts
@@ -2,6 +2,7 @@ import type { PermissionKey } from '@/models/access-control'
export const AppACLPermission = {
Preview: 'app.acl.preview',
+ AccessPoint: 'app.acl.access_point_manage',
ViewLayout: 'app.acl.view_layout',
TestAndRun: 'app.acl.test_and_run',
Edit: 'app.acl.edit',
@@ -38,6 +39,7 @@ export type ResourceMaintainerPermissionOptions = {
}
type AppACLCapabilities = {
+ canAccessPoint: boolean
canViewLayout: boolean
canTestAndRun: boolean
canEdit: boolean
@@ -135,6 +137,11 @@ export const getAppACLCapabilities = (
)
return {
+ canAccessPoint: hasResourcePermission(
+ permissionKeys,
+ AppACLPermission.AccessPoint,
+ hasMaintainerPermissions,
+ ),
canViewLayout,
canTestAndRun,
canEdit,