refactor(web): clarify app context bootstrap graph (#38516)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-07-07 22:08:38 +08:00 committed by GitHub
parent 6edce14e88
commit 56f3d0a11e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 721 additions and 302 deletions

View File

@ -7031,11 +7031,6 @@
"count": 1
}
},
"web/service/access-control/__tests__/use-permission-keys.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": {
"no-restricted-imports": {
"count": 1
@ -7056,11 +7051,6 @@
"count": 1
}
},
"web/service/access-control/use-permission-keys.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/use-workspace-access-rules.ts": {
"no-restricted-imports": {
"count": 1
@ -7228,17 +7218,6 @@
"count": 1
}
},
"web/service/use-common.ts": {
"no-restricted-imports": {
"count": 1
},
"ts/no-empty-object-type": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/service/use-datasource.ts": {
"no-restricted-imports": {
"count": 1

View File

@ -1,8 +1,18 @@
import { render, screen } from '@testing-library/react'
import { useAppContext, useSelector } from '../app-context'
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { useHydrateAtoms } from 'jotai/react/utils'
import { Suspense } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { initialWorkspaceInfo, useAppContext, useSelector } from '../app-context'
import { AppContextProvider } from '../app-context-provider'
const mockInvalidateQueries = vi.hoisted(() => vi.fn())
const mockGetRequest = vi.hoisted(() => vi.fn())
const mockPermissionKeysState = vi.hoisted(() => ({
isPending: false,
permissionKeys: ['app.create_and_management'],
@ -19,55 +29,84 @@ const mockCurrentWorkspaceResponse = vi.hoisted(() => ({
next_credit_reset_date: 1706745600,
custom_config: {},
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
}),
useSuspenseQuery: (options: { queryKey?: readonly unknown[] }) => {
if (options.queryKey?.[0] === 'system-features') {
return {
data: {
branding: {
enabled: false,
},
},
}
const mockCurrentWorkspaceQueryState = vi.hoisted(() => ({
data: mockCurrentWorkspaceResponse as typeof mockCurrentWorkspaceResponse | undefined,
isPending: false,
}))
const mockUserProfileResponseState = vi.hoisted(() => ({
data: {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
} as {
profile?: {
id: string
name: string
email: string
avatar: string
avatar_url: string
is_password_set: boolean
}
return {
data: {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
},
meta: {
currentVersion: string | null
currentEnv: string | null
}
},
useQuery: (options: { select?: (workspace: typeof mockCurrentWorkspaceResponse) => unknown }) => ({
data: options.select ? options.select(mockCurrentWorkspaceResponse) : mockCurrentWorkspaceResponse,
isFetching: false,
isPending: false,
}),
}))
const mockSystemFeaturesState = vi.hoisted(() => ({
data: {
branding: {
enabled: false,
},
},
}))
const mockLangGeniusVersionState = vi.hoisted(() => ({
data: {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
} as {
version: string
release_date: string
release_notes: string
can_auto_update: boolean
} | undefined,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
ZENDESK_FIELD_IDS: {
ENVIRONMENT: 'environment-field',
VERSION: 'version-field',
EMAIL: 'email-field',
WORKSPACE_ID: 'workspace-id-field',
},
}
})
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: async () => mockSystemFeaturesState.data,
}),
}))
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({
queryKey: ['user-profile'],
queryFn: async () => mockUserProfileResponseState.data,
}),
}))
@ -77,8 +116,16 @@ vi.mock('@/service/client', () => ({
current: {
post: {
key: () => ['current-workspace'],
queryOptions: (options: Record<string, unknown>) => ({
queryOptions: (options: {
select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown
}) => ({
queryKey: ['current-workspace'],
queryFn: async () => {
if (mockCurrentWorkspaceQueryState.isPending)
return new Promise(() => {})
return mockCurrentWorkspaceQueryState.data
},
...options,
}),
},
@ -87,34 +134,9 @@ vi.mock('@/service/client', () => ({
},
}))
vi.mock('@/service/access-control/use-permission-keys', () => ({
useWorkspacePermissionKeys: () => ({
data: {
workspace: {
permission_keys: mockPermissionKeysState.permissionKeys,
},
app: {
default_permission_keys: [],
overrides: [],
},
dataset: {
default_permission_keys: [],
overrides: [],
},
},
isPending: mockPermissionKeysState.isPending,
}),
}))
vi.mock('@/service/use-common', () => ({
useLangGeniusVersion: () => ({
data: {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
},
}),
vi.mock('@/service/base', () => ({
get: mockGetRequest,
post: vi.fn(),
}))
vi.mock('@/app/components/base/amplitude', () => ({
@ -145,35 +167,303 @@ function AppContextProbe() {
{selectedWorkspacePermissionKeys.join(',')}
</span>
<span>
loading:
permission loading:
{String(context.isLoadingWorkspacePermissionKeys)}
</span>
<span>
workspace loading:
{String(context.isLoadingCurrentWorkspace)}
</span>
<span>
workspace validating:
{String(context.isValidatingCurrentWorkspace)}
</span>
<span>
user:
{context.userProfile.email}
</span>
<span>
workspace:
{context.currentWorkspace.name}
</span>
<span>
role:
{context.currentWorkspace.role}
</span>
<span>
manager:
{String(context.isCurrentWorkspaceManager)}
</span>
<span>
owner:
{String(context.isCurrentWorkspaceOwner)}
</span>
<span>
editor:
{String(context.isCurrentWorkspaceEditor)}
</span>
<span>
dataset operator:
{String(context.isCurrentWorkspaceDatasetOperator)}
</span>
<span>
version:
{context.langGeniusVersionInfo.current_version}
/
{context.langGeniusVersionInfo.latest_version}
/
{context.langGeniusVersionInfo.current_env}
</span>
<button type="button" onClick={context.mutateUserProfile}>refresh user</button>
<button type="button" onClick={context.mutateCurrentWorkspace}>refresh workspace</button>
</>
)
}
function TestQueryClientHydrator({
children,
queryClient,
}: {
children: ReactNode
queryClient: QueryClient
}) {
useHydrateAtoms(new Map([[queryClientAtom, queryClient]]))
return children
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
})
}
function renderProvider() {
const queryClient = createTestQueryClient()
const view = render(
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<TestQueryClientHydrator queryClient={queryClient}>
<Suspense fallback={<span>loading</span>}>
<AppContextProvider>
<AppContextProbe />
</AppContextProvider>
</Suspense>
</TestQueryClientHydrator>
</QueryClientProvider>
</JotaiProvider>,
)
return {
...view,
queryClient,
}
}
describe('AppContextProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPermissionKeysState.isPending = false
mockPermissionKeysState.permissionKeys = ['app.create_and_management']
mockCurrentWorkspaceQueryState.data = mockCurrentWorkspaceResponse
mockCurrentWorkspaceQueryState.isPending = false
mockUserProfileResponseState.data = {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
}
mockSystemFeaturesState.data = {
branding: {
enabled: false,
},
}
mockLangGeniusVersionState.data = {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
}
mockGetRequest.mockImplementation((url: string) => {
if (url === '/workspaces/current/rbac/my-permissions') {
if (mockPermissionKeysState.isPending)
return new Promise(() => {})
return Promise.resolve({
workspace: {
permission_keys: mockPermissionKeysState.permissionKeys,
},
app: {
default_permission_keys: [],
overrides: [],
},
dataset: {
default_permission_keys: [],
overrides: [],
},
})
}
if (url === '/version')
return Promise.resolve(mockLangGeniusVersionState.data)
return Promise.reject(new Error(`Unexpected GET ${url}`))
})
})
describe('Workspace Permission Keys', () => {
it('should provide current workspace permission keys from my-permissions', () => {
render(
<AppContextProvider>
<AppContextProbe />
</AppContextProvider>,
)
describe('Context compatibility values', () => {
it('should provide profile, workspace, permissions, loading state, and version metadata', async () => {
renderProvider()
expect(screen.getByText('keys:app.create_and_management')).toBeInTheDocument()
expect(screen.getByText('loading:false')).toBeInTheDocument()
expect(screen.getByText('role:editor')).toBeInTheDocument()
expect(await screen.findByText('user:user@example.com')).toBeInTheDocument()
expect(await screen.findByText('workspace:Workspace')).toBeInTheDocument()
expect(await screen.findByText('keys:app.create_and_management')).toBeInTheDocument()
expect(screen.getByText('permission loading:false')).toBeInTheDocument()
expect(screen.getByText('workspace loading:false')).toBeInTheDocument()
expect(screen.getByText('workspace validating:false')).toBeInTheDocument()
expect(await screen.findByText('version:1.0.0/1.0.1/cloud')).toBeInTheDocument()
})
it('should fall back to placeholder values when profile, workspace, permission, or version data is missing', async () => {
mockUserProfileResponseState.data = {
meta: {
currentVersion: null,
currentEnv: null,
},
}
mockCurrentWorkspaceQueryState.data = undefined
mockPermissionKeysState.permissionKeys = []
mockLangGeniusVersionState.data = undefined
renderProvider()
expect(await screen.findByText('user:')).toBeInTheDocument()
expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument()
expect(screen.getByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
expect(screen.getByText('keys:')).toBeInTheDocument()
expect(screen.getByText('version://')).toBeInTheDocument()
})
it('should normalize invalid workspace roles to the initial workspace role', async () => {
mockCurrentWorkspaceQueryState.data = {
...mockCurrentWorkspaceResponse,
role: 'unsupported-role',
}
renderProvider()
expect(await screen.findByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
})
it('should derive role flags from the current workspace role', async () => {
mockCurrentWorkspaceQueryState.data = {
...mockCurrentWorkspaceResponse,
role: 'owner',
}
renderProvider()
expect(await screen.findByText('manager:true')).toBeInTheDocument()
expect(screen.getByText('owner:true')).toBeInTheDocument()
expect(screen.getByText('editor:true')).toBeInTheDocument()
expect(screen.getByText('dataset operator:false')).toBeInTheDocument()
})
it('should expose query loading and validating state', async () => {
mockPermissionKeysState.isPending = true
mockCurrentWorkspaceQueryState.isPending = true
renderProvider()
expect(await screen.findByText('workspace loading:true')).toBeInTheDocument()
expect(screen.getByText('workspace validating:true')).toBeInTheDocument()
expect(screen.getByText('permission loading:true')).toBeInTheDocument()
})
})
describe('Refresh actions', () => {
it('should invalidate the source queries when refresh actions are called', async () => {
const { queryClient } = renderProvider()
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: /refresh user/i }))
fireEvent.click(screen.getByRole('button', { name: /refresh workspace/i }))
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['user-profile'] })
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['current-workspace'] })
})
})
describe('External side effects', () => {
it('should sync Zendesk fields and Amplitude identity when bootstrap data is available', async () => {
renderProvider()
await waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: 'cloud',
}])
})
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.VERSION,
value: '1.0.1',
}])
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: 'user@example.com',
}])
await waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: 'workspace-1',
}])
})
await waitFor(() => {
expect(setUserId).toHaveBeenCalledWith('user@example.com')
expect(setUserProperties).toHaveBeenCalledWith(expect.objectContaining({
email: 'user@example.com',
workspace_id: 'workspace-1',
workspace_role: 'editor',
}))
expect(flushRegistrationSuccess).toHaveBeenCalled()
})
})
it('should not sync Amplitude identity when user id is missing', async () => {
mockUserProfileResponseState.data = {
profile: {
id: '',
name: '',
email: '',
avatar: '',
avatar_url: '',
is_password_set: false,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
}
renderProvider()
await screen.findByText('user:')
expect(setUserId).not.toHaveBeenCalled()
expect(setUserProperties).not.toHaveBeenCalled()
expect(flushRegistrationSuccess).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,82 @@
'use client'
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import {
currentWorkspaceAtom,
langGeniusVersionInfoAtom,
userProfileAtom,
} from './app-context-state'
export function useSyncZendeskFields() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
useEffect(() => {
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: langGeniusVersionInfo.current_env.toLowerCase(),
}])
}
}, [langGeniusVersionInfo?.current_env])
useEffect(() => {
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.VERSION,
value: langGeniusVersionInfo.version,
}])
}
}, [langGeniusVersionInfo?.version])
useEffect(() => {
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
}])
}
}, [userProfile?.email])
useEffect(() => {
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: currentWorkspace.id,
}])
}
}, [currentWorkspace?.id])
}
export function useSyncAmplitudeIdentity() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
useEffect(() => {
if (userProfile?.id) {
setUserId(userProfile.email)
const properties: Record<string, string | number | boolean> = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace?.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
setUserProperties(properties)
flushRegistrationSuccess()
}
}, [userProfile, currentWorkspace])
}

View File

@ -0,0 +1,78 @@
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from './app-context'
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
export const emptyWorkspacePermissionKeys: string[] = []
export type WorkspaceRoleFlags = {
isCurrentWorkspaceManager: boolean
isCurrentWorkspaceOwner: boolean
isCurrentWorkspaceEditor: boolean
isCurrentWorkspaceDatasetOperator: boolean
}
export type ProfileMeta = {
currentVersion: string | null
currentEnv: string | null
}
function resolveWorkspaceRole(role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] {
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
return role as ICurrentWorkspace['role']
return initialWorkspaceInfo.role
}
export function 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 function getWorkspaceRoleFlags(currentWorkspace: ICurrentWorkspace): WorkspaceRoleFlags {
return {
isCurrentWorkspaceManager: ['owner', 'admin'].includes(currentWorkspace.role),
isCurrentWorkspaceOwner: currentWorkspace.role === 'owner',
isCurrentWorkspaceEditor: ['owner', 'admin', 'editor'].includes(currentWorkspace.role),
isCurrentWorkspaceDatasetOperator: currentWorkspace.role === 'dataset_operator',
}
}
export function getLangGeniusVersionInfo({
meta,
versionData,
}: {
meta: ProfileMeta
versionData?: Omit<LangGeniusVersionResponse, 'current_version' | 'latest_version' | 'current_env'>
}): LangGeniusVersionResponse {
if (!meta.currentVersion || !versionData)
return initialLangGeniusVersionInfo
return {
...versionData,
current_version: meta.currentVersion,
latest_version: versionData.version,
current_env: meta.currentEnv || '',
}
}

View File

@ -1,193 +1,65 @@
'use client'
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { useAtomValue, useSetAtom } from 'jotai'
import {
AppContext,
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
userProfilePlaceholder,
useSelector,
} from '@/context/app-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useWorkspacePermissionKeys } from '@/service/access-control/use-permission-keys'
import { consoleQuery } from '@/service/client'
import {
useLangGeniusVersion,
} from '@/service/use-common'
currentWorkspaceAtom,
currentWorkspaceLoadingAtom,
currentWorkspaceValidatingAtom,
langGeniusVersionInfoAtom,
refreshCurrentWorkspaceAtom,
refreshUserProfileAtom,
userProfileAtom,
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
workspaceRoleFlagsAtom,
} from '@/context/app-context-state'
import {
useSyncAmplitudeIdentity,
useSyncZendeskFields,
} from './app-context-effects'
type AppContextProviderProps = {
children: ReactNode
}
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
const emptyWorkspacePermissionKeys: string[] = []
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 function AppContextProvider({ children }: AppContextProviderProps) {
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const currentWorkspaceQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({
select: normalizeCurrentWorkspace,
}))
const workspacePermissionKeysQuery = useWorkspacePermissionKeys()
const langGeniusVersionQuery = useLangGeniusVersion(
userProfileResp?.meta.currentVersion,
!systemFeatures.branding.enabled,
)
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const roleFlags = useAtomValue(workspaceRoleFlagsAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const isValidatingCurrentWorkspace = useAtomValue(currentWorkspaceValidatingAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
const userProfile = useMemo<GetAccountProfileResponse>(() => userProfileResp?.profile || userProfilePlaceholder, [userProfileResp?.profile])
const currentWorkspace = currentWorkspaceQuery.data ?? initialWorkspaceInfo
const langGeniusVersionInfo = useMemo<LangGeniusVersionResponse>(() => {
if (!userProfileResp?.meta?.currentVersion || !langGeniusVersionQuery.data)
return initialLangGeniusVersionInfo
const refreshUserProfile = useSetAtom(refreshUserProfileAtom)
const refreshCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom)
const current_version = userProfileResp.meta.currentVersion
const current_env = userProfileResp.meta.currentEnv || ''
const versionData = langGeniusVersionQuery.data
return {
...versionData,
current_version,
latest_version: versionData.version,
current_env,
}
}, [langGeniusVersionQuery.data, userProfileResp?.meta])
const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role])
const isCurrentWorkspaceOwner = useMemo(() => currentWorkspace.role === 'owner', [currentWorkspace.role])
const isCurrentWorkspaceEditor = useMemo(() => ['owner', 'admin', 'editor'].includes(currentWorkspace.role), [currentWorkspace.role])
const isCurrentWorkspaceDatasetOperator = useMemo(() => currentWorkspace.role === 'dataset_operator', [currentWorkspace.role])
const mutateUserProfile = useCallback(() => {
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
}, [queryClient])
const mutateCurrentWorkspace = useCallback(() => {
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
}, [queryClient])
// #region Zendesk conversation fields
useEffect(() => {
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: langGeniusVersionInfo.current_env.toLowerCase(),
}])
}
}, [langGeniusVersionInfo?.current_env])
useEffect(() => {
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.VERSION,
value: langGeniusVersionInfo.version,
}])
}
}, [langGeniusVersionInfo?.version])
useEffect(() => {
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
}])
}
}, [userProfile?.email])
useEffect(() => {
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: currentWorkspace.id,
}])
}
}, [currentWorkspace?.id])
// #endregion Zendesk conversation fields
useEffect(() => {
// Report user and workspace info to Amplitude when loaded
if (userProfile?.id) {
setUserId(userProfile.email)
const properties: Record<string, string | number | boolean> = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace?.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
setUserProperties(properties)
// The user ID is now attached, so replay any registration success event captured
// at signup time. This makes it land on the identified Amplitude profile instead
// of an anonymous one (no-op when nothing was deferred).
flushRegistrationSuccess()
}
}, [userProfile, currentWorkspace])
useSyncZendeskFields()
useSyncAmplitudeIdentity()
return (
<AppContext.Provider value={{
userProfile,
mutateUserProfile,
mutateUserProfile: () => {
refreshUserProfile()
},
langGeniusVersionInfo,
useSelector,
currentWorkspace,
isCurrentWorkspaceManager,
isCurrentWorkspaceOwner,
isCurrentWorkspaceEditor,
isCurrentWorkspaceDatasetOperator,
mutateCurrentWorkspace,
isLoadingCurrentWorkspace: currentWorkspaceQuery.isPending,
isLoadingWorkspacePermissionKeys: workspacePermissionKeysQuery.isPending,
isValidatingCurrentWorkspace: currentWorkspaceQuery.isFetching,
workspacePermissionKeys: workspacePermissionKeysQuery.data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys,
...roleFlags,
mutateCurrentWorkspace: () => {
refreshCurrentWorkspace()
},
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
isValidatingCurrentWorkspace,
workspacePermissionKeys,
}}
>
{children}

View File

@ -0,0 +1,116 @@
'use client'
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
import type { DefinedQueryObserverResult } from '@tanstack/react-query'
import type { UserProfileWithMeta } from '@/features/account-profile/client'
import { atom } from 'jotai'
import { atomWithQuery, atomWithSuspenseQuery, queryClientAtom } from 'jotai-tanstack-query'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { workspacePermissionKeysQueryOptions } from '@/service/access-control/use-permission-keys'
import { consoleQuery } from '@/service/client'
import { langGeniusVersionQueryOptions } from '@/service/lang-genius-version'
import {
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
userProfilePlaceholder,
} from './app-context'
import {
emptyWorkspacePermissionKeys,
getLangGeniusVersionInfo,
getWorkspaceRoleFlags,
normalizeCurrentWorkspace,
} from './app-context-normalizers'
type SuspenseQueryResult<T> = Omit<DefinedQueryObserverResult<T>, 'isPlaceholderData'>
const accountProfileQueryAtom = atomWithSuspenseQuery(() => userProfileQueryOptions())
const systemFeaturesQueryAtom = atomWithSuspenseQuery(() => systemFeaturesQueryOptions())
export const userProfileAtom = atom((get): GetAccountProfileResponse => {
const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult<UserProfileWithMeta>
return accountProfileQuery.data?.profile || userProfilePlaceholder
})
const profileMetaAtom = atom((get) => {
const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult<UserProfileWithMeta>
return accountProfileQuery.data?.meta ?? {
currentVersion: null,
currentEnv: null,
}
})
const currentWorkspaceQueryAtom = atomWithQuery(() => {
return consoleQuery.workspaces.current.post.queryOptions({
select: normalizeCurrentWorkspace,
})
})
const normalizedCurrentWorkspaceAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceInfo
})
export const currentWorkspaceAtom = atom((get) => {
return get(normalizedCurrentWorkspaceAtom)
})
export const workspaceRoleFlagsAtom = atom((get) => {
return getWorkspaceRoleFlags(get(currentWorkspaceAtom))
})
const workspacePermissionKeysQueryAtom = atomWithQuery((get) => {
const workspaceId = get(currentWorkspaceAtom).id
return workspacePermissionKeysQueryOptions(workspaceId)
})
export const workspacePermissionKeysAtom = atom((get) => {
return get(workspacePermissionKeysQueryAtom).data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys
})
export const workspacePermissionKeysLoadingAtom = atom((get) => {
return get(workspacePermissionKeysQueryAtom).isPending
})
export const currentWorkspaceLoadingAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).isPending
})
export const currentWorkspaceValidatingAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).isFetching
})
const versionQueryAtom = atomWithQuery((get) => {
const meta = get(profileMetaAtom)
const systemFeaturesQuery = get(systemFeaturesQueryAtom) as SuspenseQueryResult<GetSystemFeaturesResponse>
const enabled = Boolean(meta.currentVersion && !systemFeaturesQuery.data?.branding.enabled)
return langGeniusVersionQueryOptions(meta.currentVersion, enabled)
})
export const langGeniusVersionInfoAtom = atom((get) => {
const meta = get(profileMetaAtom)
const versionData = get(versionQueryAtom).data
if (!versionData)
return initialLangGeniusVersionInfo
return getLangGeniusVersionInfo({
meta,
versionData,
})
})
export const refreshUserProfileAtom = atom(null, (get) => {
const queryClient = get(queryClientAtom)
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
})
export const refreshCurrentWorkspaceAtom = atom(null, (get) => {
const queryClient = get(queryClientAtom)
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
})

View File

@ -1,26 +1,13 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get } from '@/service/base'
import { useWorkspacePermissionKeys } from '../use-permission-keys'
import { workspacePermissionKeysQueryOptions } from '../use-permission-keys'
vi.mock('@/service/base', () => ({
get: vi.fn(),
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('useWorkspacePermissionKeys', () => {
describe('workspacePermissionKeysQueryOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(get).mockResolvedValue({
@ -33,11 +20,15 @@ describe('useWorkspacePermissionKeys', () => {
// Current-user permissions come from the my-permissions RBAC endpoint.
describe('Queries', () => {
it('should fetch workspace permission keys', async () => {
renderHook(() => useWorkspacePermissionKeys(), { wrapper: createWrapper() })
await waitFor(() => {
expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions')
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
await queryClient.fetchQuery(workspacePermissionKeysQueryOptions())
expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions')
})
})
})

View File

@ -1,12 +1,18 @@
import type { PermissionKeysResponse } from '@/models/access-control'
import { useQuery } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get } from '../base'
const NAME_SPACE = 'workspace-permission-keys'
export const useWorkspacePermissionKeys = () => {
return useQuery({
queryKey: [NAME_SPACE],
const workspacePermissionKeysQueryKey = (workspaceId?: string) => {
return workspaceId ? [NAME_SPACE, workspaceId] as const : [NAME_SPACE] as const
}
export const workspacePermissionKeysQueryOptions = (workspaceId?: string) => {
return queryOptions<PermissionKeysResponse>({
queryKey: workspacePermissionKeysQueryKey(workspaceId),
queryFn: () => get<PermissionKeysResponse>('/workspaces/current/rbac/my-permissions'),
enabled: workspaceId === undefined || Boolean(workspaceId),
})
}

View File

@ -0,0 +1,13 @@
import type { LangGeniusVersionResponse } from '@/models/common'
import { queryOptions } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get } from './base'
import { commonQueryKeys } from './use-common'
export const langGeniusVersionQueryOptions = (currentVersion?: string | null, enabled?: boolean) => {
return queryOptions<LangGeniusVersionResponse>({
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
queryFn: () => get<LangGeniusVersionResponse>('/version', { params: { current_version: currentVersion } }),
enabled: !!currentVersion && (enabled ?? true),
})
}

View File

@ -10,13 +10,13 @@ import type {
CodeBasedExtension,
CommonResponse,
FileUploadConfigResponse,
LangGeniusVersionResponse,
Member,
StructuredOutputRulesRequestBody,
StructuredOutputRulesResponse,
} from '@/models/common'
import type { RETRIEVE_METHOD } from '@/types/app'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get, post } from './base'
const NAME_SPACE = 'common'
@ -54,14 +54,6 @@ export const useFileUploadConfig = () => {
})
}
export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: boolean) => {
return useQuery<LangGeniusVersionResponse>({
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
queryFn: () => get<LangGeniusVersionResponse>('/version', { params: { current_version: currentVersion } }),
enabled: !!currentVersion && (enabled ?? true),
})
}
export const useGenerateStructuredOutputRules = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'generate-structured-output-rules'],
@ -95,7 +87,7 @@ export const useMailValidity = () => {
})
}
export type MailRegisterResponse = { result: string, data: {} }
export type MailRegisterResponse = { result: string, data: Record<string, never> }
export const useMailRegister = () => {
return useMutation({
@ -149,7 +141,7 @@ export const useFilePreview = (fileID: string) => {
export type SchemaTypeDefinition = {
name: string
schema: {
properties: Record<string, any>
properties: Record<string, unknown>
}
}