Compare commits

..

2 Commits

Author SHA1 Message Date
a4ccf5680d fix(web): sort new i18n keys to satisfy jsonc/sort-keys
Move the M3 forwardUserIdentity / forwardUserIdentityTip entries to their
alphabetical position (between editTitle and headerKey) so the lint rule
`jsonc/sort-keys` passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 20:02:33 -07:00
029b836234 feat(web): add Forward-user-identity toggle to MCP provider modal (M3)
Exposes the M2 backend flags as a switch on the MCP provider create/edit
modal so workspace admins can opt in to enterprise SSO identity-forwarding
per provider. When the toggle flips on, the modal sends
forward_user_identity=true + identity_mode="idp_token" to the console API
(which the M2 backend persists on tool_mcp_providers).

- The toggle lives between Server Identifier and the Authentication tabs;
  it overrides the static Authorization (from Auth/Headers) at invoke time.
- The form-state hook hydrates from the GET response so editing preserves
  the previous choice across sessions.
- en-US + zh-Hans strings added; other locales fall back to en-US.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:53:36 -07:00
25 changed files with 152 additions and 176 deletions

View File

@ -29,7 +29,7 @@ from controllers.console.wraps import (
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import TimestampField, dump_response, to_timestamp
from libs.helper import TimestampField, to_timestamp
from libs.login import current_account_with_tenant, login_required
from models.account import Tenant, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
@ -56,11 +56,6 @@ class WorkspaceCustomConfigPayload(BaseModel):
replace_webapp_logo: str | None = None
class WorkspaceCustomConfigResponse(ResponseModel):
remove_webapp_brand: bool | None = None
replace_webapp_logo: str | None = None
class WorkspaceInfoPayload(BaseModel):
name: str
@ -74,7 +69,7 @@ class TenantInfoResponse(ResponseModel):
role: str | None = None
in_trial: bool | None = None
trial_end_reason: str | None = None
custom_config: WorkspaceCustomConfigResponse | None = None
custom_config: dict | None = None
trial_credits: int | None = None
trial_credits_used: int | None = None
next_credit_reset_date: int | None = None
@ -106,13 +101,9 @@ register_schema_models(
SwitchWorkspacePayload,
WorkspaceCustomConfigPayload,
WorkspaceInfoPayload,
)
register_response_schema_models(
console_ns,
TenantInfoResponse,
WorkspaceCustomConfigResponse,
WorkspacePermissionResponse,
)
register_response_schema_models(console_ns, WorkspacePermissionResponse)
provider_fields = {
"provider_name": fields.String,
@ -247,7 +238,13 @@ class TenantApi(Resource):
else:
raise Unauthorized("workspace is archived")
return dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant)), 200
return (
TenantInfoResponse.model_validate(
WorkspaceService.get_tenant_info(tenant),
from_attributes=True,
).model_dump(mode="json"),
200,
)
@console_ns.route("/workspaces/switch")

View File

@ -15207,7 +15207,7 @@ Tag type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | No |
| custom_config | [WorkspaceCustomConfigResponse](#workspacecustomconfigresponse) | | No |
| custom_config | object | | No |
| id | string | | Yes |
| in_trial | boolean | | No |
| name | string | | No |
@ -16330,13 +16330,6 @@ Workflow tool configuration
| remove_webapp_brand | boolean | | No |
| replace_webapp_logo | string | | No |
#### WorkspaceCustomConfigResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| remove_webapp_brand | boolean | | No |
| replace_webapp_logo | string | | No |
#### WorkspaceInfoPayload
| Name | Type | Description | Required |

View File

@ -435,23 +435,6 @@ class TestTenantInfoResponse:
assert payload["plan"] == "team"
assert payload["created_at"] == int(created_at.timestamp())
def test_tenant_info_response_has_typed_custom_config(self):
payload = TenantInfoResponse.model_validate(
{
"id": "t1",
"custom_config": {
"remove_webapp_brand": True,
"replace_webapp_logo": "logo-file-id",
"ignored": "value",
},
}
).model_dump(mode="json")
assert payload["custom_config"] == {
"remove_webapp_brand": True,
"replace_webapp_logo": "logo-file-id",
}
class TestSwitchWorkspaceApi:
def test_switch_success(self, app: Flask):

View File

@ -4904,6 +4904,11 @@
"count": 1
}
},
"web/app/device/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/education-apply/hooks.ts": {
"react/set-state-in-effect": {
"count": 5

View File

@ -4,8 +4,16 @@ import { oc } from '@orpc/contract'
import { zPostInfoResponse } from './zod.gen'
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postInfo',

View File

@ -6,7 +6,9 @@ export type ClientOptions = {
export type TenantInfoResponse = {
created_at?: number | null
custom_config?: WorkspaceCustomConfigResponse
custom_config?: {
[key: string]: unknown
} | null
id: string
in_trial?: boolean | null
name?: string | null
@ -19,11 +21,6 @@ export type TenantInfoResponse = {
trial_end_reason?: string | null
}
export type WorkspaceCustomConfigResponse = {
remove_webapp_brand?: boolean | null
replace_webapp_logo?: string | null
}
export type PostInfoData = {
body?: never
path?: never

View File

@ -2,20 +2,12 @@
import * as z from 'zod'
/**
* WorkspaceCustomConfigResponse
*/
export const zWorkspaceCustomConfigResponse = z.object({
remove_webapp_brand: z.boolean().nullish(),
replace_webapp_logo: z.string().nullish(),
})
/**
* TenantInfoResponse
*/
export const zTenantInfoResponse = z.object({
created_at: z.int().nullish(),
custom_config: zWorkspaceCustomConfigResponse.optional(),
custom_config: z.record(z.string(), z.unknown()).nullish(),
id: z.string(),
in_trial: z.boolean().nullish(),
name: z.string().nullish(),

View File

@ -3682,8 +3682,16 @@ export const triggers = {
get: get58,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post63 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesCurrent',

View File

@ -6,7 +6,9 @@ export type ClientOptions = {
export type TenantInfoResponse = {
created_at?: number | null
custom_config?: WorkspaceCustomConfigResponse
custom_config?: {
[key: string]: unknown
} | null
id: string
in_trial?: boolean | null
name?: string | null
@ -498,11 +500,6 @@ export type SwitchWorkspacePayload = {
tenant_id: string
}
export type WorkspaceCustomConfigResponse = {
remove_webapp_brand?: boolean | null
replace_webapp_logo?: string | null
}
export type AccountWithRole = {
avatar?: string | null
created_at?: number | null

View File

@ -2,6 +2,24 @@
import * as z from 'zod'
/**
* TenantInfoResponse
*/
export const zTenantInfoResponse = z.object({
created_at: z.int().nullish(),
custom_config: z.record(z.string(), z.unknown()).nullish(),
id: z.string(),
in_trial: z.boolean().nullish(),
name: z.string().nullish(),
next_credit_reset_date: z.int().nullish(),
plan: z.string().nullish(),
role: z.string().nullish(),
status: z.string().nullish(),
trial_credits: z.int().nullish(),
trial_credits_used: z.int().nullish(),
trial_end_reason: z.string().nullish(),
})
/**
* SimpleResultResponse
*/
@ -437,32 +455,6 @@ export const zSwitchWorkspacePayload = z.object({
tenant_id: z.string(),
})
/**
* WorkspaceCustomConfigResponse
*/
export const zWorkspaceCustomConfigResponse = z.object({
remove_webapp_brand: z.boolean().nullish(),
replace_webapp_logo: z.string().nullish(),
})
/**
* TenantInfoResponse
*/
export const zTenantInfoResponse = z.object({
created_at: z.int().nullish(),
custom_config: zWorkspaceCustomConfigResponse.optional(),
id: z.string(),
in_trial: z.boolean().nullish(),
name: z.string().nullish(),
next_credit_reset_date: z.int().nullish(),
plan: z.string().nullish(),
role: z.string().nullish(),
status: z.string().nullish(),
trial_credits: z.int().nullish(),
trial_credits_used: z.int().nullish(),
trial_end_reason: z.string().nullish(),
})
/**
* AccountWithRole
*/

View File

@ -131,6 +131,14 @@ vi.mock('@/service/use-common', () => ({
],
},
}),
useCurrentWorkspace: () => ({
data: {
trial_credits: 1000,
trial_credits_used: 100,
next_credit_reset_date: undefined,
},
isPending: false,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({

View File

@ -7,11 +7,11 @@ import QuotaPanel from '../quota-panel'
let mockWorkspaceData: {
trial_credits: number
trial_credits_used: number
next_credit_reset_date: number
next_credit_reset_date: string
} | undefined = {
trial_credits: 100,
trial_credits_used: 30,
next_credit_reset_date: 1735603200,
next_credit_reset_date: '2024-12-31',
}
let mockWorkspaceIsPending = false
let mockTrialModels: string[] | undefined = ['langgenius/openai/openai']
@ -32,18 +32,11 @@ vi.mock('@/app/components/base/icons/src/public/llm', () => {
}
})
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => {
const totalCredits = mockWorkspaceData?.trial_credits ?? 0
const credits = Math.max(totalCredits - (mockWorkspaceData?.trial_credits_used ?? 0), 0)
return {
credits,
totalCredits,
isExhausted: credits <= 0,
isLoading: mockWorkspaceIsPending && !mockWorkspaceData,
nextCreditResetDate: mockWorkspaceData?.next_credit_reset_date,
}
},
vi.mock('@/service/use-common', () => ({
useCurrentWorkspace: () => ({
data: mockWorkspaceData,
isPending: mockWorkspaceIsPending,
}),
}))
const renderQuotaPanel = (ui: ReactElement) => renderWithSystemFeatures(ui, {
@ -85,7 +78,7 @@ describe('QuotaPanel', () => {
mockWorkspaceData = {
trial_credits: 100,
trial_credits_used: 30,
next_credit_reset_date: 1735603200,
next_credit_reset_date: '2024-12-31',
}
mockWorkspaceIsPending = false
mockTrialModels = ['langgenius/openai/openai']
@ -125,7 +118,7 @@ describe('QuotaPanel', () => {
mockWorkspaceData = {
trial_credits: 10,
trial_credits_used: 999,
next_credit_reset_date: 0,
next_credit_reset_date: '',
}
renderQuotaPanel(<QuotaPanel providers={mockProviders} />)

View File

@ -1,34 +1,20 @@
import { renderHook } from '@testing-library/react'
import { useTrialCredits } from '../use-trial-credits'
const { mockUseQuery } = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
}))
const mockUseCurrentWorkspace = vi.fn()
vi.mock('@tanstack/react-query', () => ({
useQuery: () => mockUseQuery(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
workspaces: {
current: {
post: {
queryOptions: () => ({ queryKey: ['console', 'workspaces', 'current', 'post'] }),
},
},
},
},
vi.mock('@/service/use-common', () => ({
useCurrentWorkspace: () => mockUseCurrentWorkspace(),
}))
describe('useTrialCredits', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockReturnValue({
mockUseCurrentWorkspace.mockReturnValue({
data: {
trial_credits: 100,
trial_credits_used: 40,
next_credit_reset_date: 1775001600,
next_credit_reset_date: '2026-04-01',
},
isPending: false,
})
@ -43,16 +29,16 @@ describe('useTrialCredits', () => {
totalCredits: 100,
isExhausted: false,
isLoading: false,
nextCreditResetDate: 1775001600,
nextCreditResetDate: '2026-04-01',
})
})
it('should keep the hook out of loading state during a background refetch', () => {
mockUseQuery.mockReturnValue({
mockUseCurrentWorkspace.mockReturnValue({
data: {
trial_credits: 80,
trial_credits_used: 20,
next_credit_reset_date: 1777593600,
next_credit_reset_date: '2026-05-01',
},
isPending: true,
})
@ -67,7 +53,7 @@ describe('useTrialCredits', () => {
describe('when workspace data is missing or exhausted', () => {
it('should report loading while the first workspace request is pending', () => {
mockUseQuery.mockReturnValue({
mockUseCurrentWorkspace.mockReturnValue({
data: undefined,
isPending: true,
})
@ -84,7 +70,7 @@ describe('useTrialCredits', () => {
})
it('should clamp negative remaining credits to zero', () => {
mockUseQuery.mockReturnValue({
mockUseCurrentWorkspace.mockReturnValue({
data: {
trial_credits: 10,
trial_credits_used: 99,

View File

@ -1,7 +1,6 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { ICurrentWorkspace } from '@/models/common'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const baseWorkspace: ICurrentWorkspace = {
@ -21,7 +20,7 @@ function createSeededQueryClient(overrides?: Partial<ICurrentWorkspace>) {
const qc = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false } },
})
qc.setQueryData(consoleQuery.workspaces.current.post.queryKey(), { ...baseWorkspace, ...overrides })
qc.setQueryData(['common', 'current-workspace'], { ...baseWorkspace, ...overrides })
return qc
}

View File

@ -1,8 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
import { useCurrentWorkspace } from '@/service/use-common'
export const useTrialCredits = () => {
const { data: currentWorkspace, isPending } = useQuery(consoleQuery.workspaces.current.post.queryOptions())
const { data: currentWorkspace, isPending } = useCurrentWorkspace()
const totalCredits = currentWorkspace?.trial_credits ?? 0
const credits = Math.max(totalCredits - (currentWorkspace?.trial_credits_used ?? 0), 0)

View File

@ -54,6 +54,7 @@ type MCPModalFormState = {
isDynamicRegistration: boolean
clientID: string
credentials: string
forwardUserIdentity: boolean
}
type MCPModalFormActions = {
setUrl: (url: string) => void
@ -68,6 +69,7 @@ type MCPModalFormActions = {
setIsDynamicRegistration: (value: boolean) => void
setClientID: (id: string) => void
setCredentials: (credentials: string) => void
setForwardUserIdentity: (value: boolean) => void
handleUrlBlur: (url: string) => Promise<void>
resetIcon: () => void
}
@ -100,6 +102,11 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true))
const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '')
const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '')
// M3 — user-identity forwarding. Identity mode is implied by the toggle:
// off → "off", on → "idp_token" (only mode currently supported).
const [forwardUserIdentity, setForwardUserIdentity] = useState(
() => Boolean(data?.forward_user_identity),
)
const handleUrlBlur = useCallback(async (urlValue: string) => {
if (data)
return
@ -163,6 +170,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
isDynamicRegistration,
clientID,
credentials,
forwardUserIdentity,
} satisfies MCPModalFormState,
// Actions
actions: {
@ -178,6 +186,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
setIsDynamicRegistration,
setClientID,
setCredentials,
setForwardUserIdentity,
handleUrlBlur,
resetIcon,
} satisfies MCPModalFormActions,

View File

@ -5,6 +5,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine, RiEditLine } from '@remixicon/react'
import { useHover } from 'ahooks'
@ -39,6 +40,8 @@ type MCPModalConfirmPayload = {
timeout: number
sse_read_timeout: number
}
forward_user_identity?: boolean
identity_mode?: 'off' | 'idp_token'
}
type DuplicateAppModalProps = {
@ -110,6 +113,8 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
timeout: state.timeout || 30,
sse_read_timeout: state.sseReadTimeout || 300,
},
forward_user_identity: state.forwardUserIdentity,
identity_mode: state.forwardUserIdentity ? 'idp_token' : 'off',
})
if (isCreate)
onHide()
@ -207,6 +212,23 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
)}
</div>
{/* Forward user identity (M3 — enterprise SSO identity-forwarding) */}
<div>
<div className="mb-1 flex h-6 items-center">
<Switch
className="mr-2"
checked={state.forwardUserIdentity}
onCheckedChange={actions.setForwardUserIdentity}
/>
<span className="system-sm-medium text-text-secondary">
{t('mcp.modal.forwardUserIdentity', { ns: 'tools' })}
</span>
</div>
<div className="body-xs-regular text-text-tertiary">
{t('mcp.modal.forwardUserIdentityTip', { ns: 'tools' })}
</div>
</div>
{/* Auth Method Tabs */}
<TabSlider
className="w-full"

View File

@ -78,6 +78,9 @@ export type Collection = {
timeout?: number
sse_read_timeout?: number
}
// M3 — user-identity forwarding (MCP)
forward_user_identity?: boolean
identity_mode?: 'off' | 'idp_token'
// Workflow
workflow_app_id?: string
}

View File

@ -1,14 +1,16 @@
'use client'
import type { ICurrentWorkspace } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import Divider from '@/app/components/base/divider'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { post } from '@/service/base'
import { deviceLookup } from '@/service/device-flow'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { commonQueryKeys } from '@/service/use-common'
import AuthorizeAccount from './components/authorize-account'
import AuthorizeSSO from './components/authorize-sso'
import Chooser from './components/chooser'
@ -50,8 +52,9 @@ export default function DevicePage() {
refetchOnMount: false,
})
const account = userResp?.profile
const { data: currentWorkspace } = useQuery({
...consoleQuery.workspaces.current.post.queryOptions(),
const { data: currentWorkspace } = useQuery<ICurrentWorkspace>({
queryKey: commonQueryKeys.currentWorkspace,
queryFn: () => post<ICurrentWorkspace>('/workspaces/current'),
enabled: !!account && !profileErr,
retry: false,
refetchOnWindowFocus: false,
@ -171,7 +174,7 @@ export default function DevicePage() {
accountEmail={account?.email}
accountName={account?.name}
accountAvatarUrl={account?.avatar_url ?? null}
defaultWorkspace={currentWorkspace?.name ?? undefined}
defaultWorkspace={currentWorkspace?.name}
onApproved={() => setView({ kind: 'success' })}
onDenied={() => setView({ kind: 'error_expired' })}
onError={e => setErrMsg(e)}

View File

@ -23,7 +23,7 @@ import {
useRouter,
useSearchParams,
} from '@/next/navigation'
import { consoleClient, consoleQuery } from '@/service/client'
import { consoleClient } from '@/service/client'
import { switchWorkspace } from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import {
@ -129,7 +129,7 @@ const EducationApplyAgeContent = () => {
try {
await switchWorkspace({ url: '/workspaces/switch', body: { tenant_id: tenantId } })
await Promise.all([
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() }),
queryClient.invalidateQueries({ queryKey: commonQueryKeys.currentWorkspace }),
queryClient.invalidateQueries({ queryKey: commonQueryKeys.workspaces }),
])
onPlanInfoChanged()

View File

@ -1,9 +1,8 @@
'use client'
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC, ReactNode } from 'react'
import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
@ -18,9 +17,9 @@ import {
} from '@/context/app-context'
import { env } from '@/env'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import {
useCurrentWorkspace,
useLangGeniusVersion,
} from '@/service/use-common'
@ -28,52 +27,18 @@ type AppContextProviderProps = {
children: ReactNode
}
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
const resolveWorkspaceRole = (role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] => {
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
return role as ICurrentWorkspace['role']
return initialWorkspaceInfo.role
}
const normalizeCurrentWorkspace = (workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace => {
if (!workspace)
return initialWorkspaceInfo
return {
id: workspace.id,
name: workspace.name ?? initialWorkspaceInfo.name,
plan: workspace.plan ?? initialWorkspaceInfo.plan,
status: workspace.status ?? initialWorkspaceInfo.status,
created_at: workspace.created_at ?? initialWorkspaceInfo.created_at,
role: resolveWorkspaceRole(workspace.role),
providers: initialWorkspaceInfo.providers,
trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits,
trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used,
next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date,
trial_end_reason: workspace.trial_end_reason ?? undefined,
custom_config: workspace.custom_config
? {
remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined,
replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined,
}
: undefined,
}
}
export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) => {
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace, isFetching: isValidatingCurrentWorkspace } = useQuery(consoleQuery.workspaces.current.post.queryOptions())
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace, isFetching: isValidatingCurrentWorkspace } = useCurrentWorkspace()
const langGeniusVersionQuery = useLangGeniusVersion(
userProfileResp?.meta.currentVersion,
!systemFeatures.branding.enabled,
)
const userProfile = useMemo<UserProfileResponse>(() => userProfileResp?.profile || userProfilePlaceholder, [userProfileResp?.profile])
const currentWorkspace = useMemo<ICurrentWorkspace>(() => normalizeCurrentWorkspace(currentWorkspaceResp), [currentWorkspaceResp])
const currentWorkspace = useMemo<ICurrentWorkspace>(() => currentWorkspaceResp || initialWorkspaceInfo, [currentWorkspaceResp])
const langGeniusVersionInfo = useMemo<LangGeniusVersionResponse>(() => {
if (!userProfileResp?.meta?.currentVersion || !langGeniusVersionQuery.data)
return initialLangGeniusVersionInfo
@ -99,7 +64,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
}, [queryClient])
const mutateCurrentWorkspace = useCallback(() => {
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
queryClient.invalidateQueries({ queryKey: ['common', 'current-workspace'] })
}, [queryClient])
// #region Zendesk conversation fields

View File

@ -120,6 +120,8 @@
"mcp.modal.configurations": "Configurations",
"mcp.modal.confirm": "Add & Authorize",
"mcp.modal.editTitle": "Edit MCP Server (HTTP)",
"mcp.modal.forwardUserIdentity": "Forward user identity",
"mcp.modal.forwardUserIdentityTip": "Send the calling user's verified SSO identity to this MCP server as an Authorization Bearer token. Requires Dify Enterprise SSO.",
"mcp.modal.headerKey": "Header Name",
"mcp.modal.headerKeyPlaceholder": "e.g., Authorization",
"mcp.modal.headerValue": "Header Value",

View File

@ -120,6 +120,8 @@
"mcp.modal.configurations": "配置",
"mcp.modal.confirm": "添加并授权",
"mcp.modal.editTitle": "修改 MCP 服务 (HTTP)",
"mcp.modal.forwardUserIdentity": "转发用户身份",
"mcp.modal.forwardUserIdentityTip": "将调用用户的已验证 SSO 身份作为 Authorization Bearer token 转发到该 MCP 服务器。需要 Dify Enterprise SSO。",
"mcp.modal.headerKey": "请求头名称",
"mcp.modal.headerKeyPlaceholder": "例如Authorization",
"mcp.modal.headerValue": "请求头值",

View File

@ -10,6 +10,7 @@ import type {
CodeBasedExtension,
CommonResponse,
FileUploadConfigResponse,
ICurrentWorkspace,
IWorkspace,
LangGeniusVersionResponse,
Member,
@ -25,6 +26,7 @@ const NAME_SPACE = 'common'
export const commonQueryKeys = {
fileUploadConfig: [NAME_SPACE, 'file-upload-config'] as const,
currentWorkspace: [NAME_SPACE, 'current-workspace'] as const,
workspaces: [NAME_SPACE, 'workspaces'] as const,
members: [NAME_SPACE, 'members'] as const,
filePreview: (fileID: string) => [NAME_SPACE, 'file-preview', fileID] as const,
@ -66,6 +68,13 @@ export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: b
})
}
export const useCurrentWorkspace = () => {
return useQuery<ICurrentWorkspace>({
queryKey: commonQueryKeys.currentWorkspace,
queryFn: () => post<ICurrentWorkspace>('/workspaces/current'),
})
}
export const useWorkspaces = () => {
return useQuery<{ workspaces: IWorkspace[] }>({
queryKey: commonQueryKeys.workspaces,

View File

@ -106,6 +106,8 @@ export const useCreateMCP = () => {
timeout?: number
sse_read_timeout?: number
headers?: Record<string, string>
forward_user_identity?: boolean
identity_mode?: 'off' | 'idp_token'
}) => {
return post<ToolWithProvider>('workspaces/current/tool-provider/mcp', {
body: {
@ -133,6 +135,8 @@ export const useUpdateMCP = ({
timeout?: number
sse_read_timeout?: number
headers?: Record<string, string>
forward_user_identity?: boolean
identity_mode?: 'off' | 'idp_token'
}) => {
return put('workspaces/current/tool-provider/mcp', {
body: {