fix(web): hydrate workspace permissions before navigation render (#39524)

This commit is contained in:
yyh
2026-07-24 17:06:57 +08:00
committed by GitHub
parent a2c67372ed
commit 4e85acc605
15 changed files with 218 additions and 89 deletions
@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
profileQueryFn: vi.fn(),
workspaceQueryFn: vi.fn(),
workspaceQueryOptions: vi.fn(),
permissionQueryFn: vi.fn(),
permissionQueryOptions: vi.fn(),
getServerConsoleClientContext: vi.fn(),
redirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`)
@@ -58,6 +60,13 @@ vi.mock('@/service/server', () => ({
post: {
queryOptions: (...args: unknown[]) => mocks.workspaceQueryOptions(...args),
},
rbac: {
myPermissions: {
get: {
queryOptions: (...args: unknown[]) => mocks.permissionQueryOptions(...args),
},
},
},
},
},
},
@@ -92,6 +101,11 @@ describe('CommonLayoutHydrationBoundary', () => {
},
})
mocks.workspaceQueryFn.mockResolvedValue({ id: 'workspace-id', name: 'Workspace' })
mocks.permissionQueryFn.mockResolvedValue({
workspace: { permission_keys: ['agent.manage'] },
app: { default_permission_keys: [], overrides: [] },
dataset: { default_permission_keys: [], overrides: [] },
})
mocks.getServerConsoleClientContext.mockResolvedValue({
cookie: 'session=abc',
csrfToken: 'csrf-token',
@@ -101,6 +115,14 @@ describe('CommonLayoutHydrationBoundary', () => {
queryFn: mocks.workspaceQueryFn,
retry: false,
})
mocks.permissionQueryOptions.mockReturnValue({
queryKey: [
['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'],
{ type: 'query' },
],
queryFn: mocks.permissionQueryFn,
retry: false,
})
})
it('should prefetch common layout queries', async () => {
@@ -126,6 +148,14 @@ describe('CommonLayoutHydrationBoundary', () => {
retry: false,
})
expect(mocks.workspaceQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.permissionQueryOptions).toHaveBeenCalledWith({
context: {
cookie: 'session=abc',
csrfToken: 'csrf-token',
},
retry: false,
})
expect(mocks.permissionQueryFn).toHaveBeenCalledTimes(1)
})
it('should dehydrate only Common-owned queries', async () => {
@@ -138,11 +168,12 @@ describe('CommonLayoutHydrationBoundary', () => {
const state = (element as ReactElement<{ state: DehydratedState }>).props.state
const queryKeys = state.queries.map((query) => query.queryKey)
expect(queryKeys).toHaveLength(2)
expect(queryKeys).toHaveLength(3)
expect(queryKeys).toEqual(
expect.arrayContaining([
['common', 'user-profile'],
['console', 'workspaces', 'current', 'post'],
[['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'], { type: 'query' }],
]),
)
})
@@ -203,5 +234,6 @@ describe('CommonLayoutHydrationBoundary', () => {
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).not.toHaveBeenCalled()
expect(mocks.workspaceQueryFn).not.toHaveBeenCalled()
expect(mocks.permissionQueryFn).not.toHaveBeenCalled()
})
})
@@ -22,6 +22,12 @@ vi.mock('@/context/permission-state', async () => {
return createPermissionStateModuleMock(() => mockConsoleStateReader())
})
vi.mock('@/features/agent-v2/permissions', () => {
return {
useCanManageAgents: () =>
mockConsoleStateReader().workspacePermissionKeys.includes('agent.manage'),
}
})
type ConsoleStateFixture = {
isLoadingCurrentWorkspace: boolean
@@ -68,8 +74,8 @@ describe('AgentsAccessGuard', () => {
expect(mockReplace).not.toHaveBeenCalled()
})
it('renders loading while workspace permission keys are loading', () => {
setConsoleState({ isLoadingWorkspacePermissionKeys: true, workspacePermissionKeys: [] })
it('renders loading while workspace permissions are loading', () => {
setConsoleState({ isLoadingWorkspacePermissionKeys: true })
render(
<AgentsAccessGuard>
@@ -15,7 +15,7 @@ export function AgentsAccessGuard({ children }: { children: ReactNode }) {
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
const canManageAgents = useCanManageAgents()
const router = useRouter()
const isLoadingAccess = isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys
const isLoadingAccess = isLoadingCurrentWorkspace || isLoadingWorkspacePermissionKeys
const shouldRedirect = !isLoadingAccess && !!currentWorkspaceId && !canManageAgents
useEffect(() => {
@@ -70,6 +70,12 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
retry: false,
}),
),
queryClient.prefetchQuery(
serverConsoleQuery.workspaces.current.rbac.myPermissions.get.queryOptions({
context,
retry: false,
}),
),
])
} catch (error) {
await handleProfileError(error)
@@ -228,6 +228,10 @@ vi.mock('react-i18next', async () => {
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const currentWorkspaceQueryKey = ['console', 'workspaces', 'current', 'post'] as const
const currentPermissionsQueryKey = [
['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'],
{ type: 'query' },
] as const
const workspacesQueryKey = ['console', 'workspaces', 'get'] as const
const consoleQuery = new Proxy(actual.consoleQuery, {
get(target, prop, receiver) {
@@ -243,6 +247,16 @@ vi.mock('@/service/client', async (importOriginal) => {
...options,
}),
},
rbac: {
myPermissions: {
get: {
queryOptions: () => ({
queryKey: currentPermissionsQueryKey,
queryFn: () => new Promise(() => {}),
}),
},
},
},
},
get: {
queryKey: () => workspacesQueryKey,
@@ -467,7 +481,11 @@ const renderMainNav = (
<MainNav />
{options.extra}
</JotaiProvider>,
{ systemFeatures: resolvedSystemFeatures, queryClient },
{
systemFeatures: resolvedSystemFeatures,
workspacePermissionKeys: currentConsoleState.workspacePermissionKeys,
queryClient,
},
)
}
@@ -1,3 +1,4 @@
import { toast } from '@langgenius/dify-ui/toast'
import { cleanup, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Plan } from '@/app/components/billing/type'
@@ -9,6 +10,21 @@ let mockProviderContext: Record<string, unknown> = {}
let mockConsoleState: Record<string, unknown> = {}
const mockFetchSubscriptionUrls = vi.hoisted(() => vi.fn())
const mockEducationAdd = vi.hoisted(() => vi.fn())
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
const mockWorkspaces = vi.hoisted(() => [
{
id: 'workspace-1',
name: 'Workspace One',
current: true,
plan: 'sandbox',
},
{
id: 'workspace-2',
name: 'Workspace Two',
current: false,
plan: 'sandbox',
},
])
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderContext,
@@ -76,12 +92,12 @@ vi.mock('@/service/client', () => ({
get: {
queryOptions: () => ({
queryKey: ['workspaces'],
queryFn: async () => ({ workspaces: [] }),
queryFn: async () => ({ workspaces: mockWorkspaces }),
}),
},
switch: {
post: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
mutationOptions: () => ({ mutationFn: mockSwitchWorkspace }),
},
},
},
@@ -95,7 +111,7 @@ const setupContext = (isCurrentWorkspaceManager: boolean) => {
onPlanInfoChanged: vi.fn(),
}
mockConsoleState = {
currentWorkspace: { id: 'workspace-1', name: 'Workspace' },
currentWorkspace: { id: 'workspace-1', name: 'Workspace One' },
isCurrentWorkspaceManager,
userProfile: {
name: 'Student',
@@ -106,7 +122,7 @@ const setupContext = (isCurrentWorkspaceManager: boolean) => {
}
const renderPage = () => {
const { wrapper } = createConsoleQueryWrapper()
const { wrapper } = createConsoleQueryWrapper({ workspacePermissionKeys: null })
return render(<EducationApplyPage />, {
wrapper,
})
@@ -116,7 +132,18 @@ describe('EducationApplyPage billing boundary', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id')
mockFetchSubscriptionUrls.mockResolvedValue({ url: window.location.href })
mockSwitchWorkspace.mockResolvedValue(undefined)
vi.stubGlobal('location', {
href: 'https://console.example.com/education-apply?token=education-token',
reload: vi.fn(),
} as unknown as Location)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('lets workspace managers apply the education coupon at checkout', async () => {
@@ -142,4 +169,49 @@ describe('EducationApplyPage billing boundary', () => {
screen.queryByRole('button', { name: 'education.useEducationDiscount' }),
).not.toBeInTheDocument()
})
it('reloads the current URL after switching workspaces', async () => {
setupContext(true)
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole('combobox'))
await user.click(await screen.findByRole('option', { name: /Workspace Two/ }))
await waitFor(() => {
expect(mockSwitchWorkspace.mock.calls[0]?.[0]).toEqual({
body: { tenant_id: 'workspace-2' },
})
expect(globalThis.location.reload).toHaveBeenCalledTimes(1)
})
})
it('disables workspace selection while switching and recovers after a failure', async () => {
setupContext(true)
let rejectSwitch!: (error: Error) => void
mockSwitchWorkspace.mockImplementation(
() =>
new Promise((_, reject) => {
rejectSwitch = reject
}),
)
const user = userEvent.setup()
renderPage()
const selector = await screen.findByRole('combobox')
await user.click(selector)
await user.click(await screen.findByRole('option', { name: /Workspace Two/ }))
await waitFor(() => {
expect(selector).toBeDisabled()
})
rejectSwitch(new Error('switch failed'))
await waitFor(() => {
expect(selector).toBeEnabled()
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully')
})
expect(globalThis.location.reload).not.toHaveBeenCalled()
})
})
@@ -15,6 +15,7 @@ type AppliedEducationContentProps = {
currentWorkspace: ICurrentWorkspace
plan: PlanType
action: ReactNode
isSwitchingWorkspace: boolean
onSwitchWorkspace: (tenantId: string) => void
}
@@ -29,6 +30,7 @@ const AppliedEducationContent = ({
currentWorkspace,
plan,
action,
isSwitchingWorkspace,
onSwitchWorkspace,
}: AppliedEducationContentProps) => {
const { t } = useTranslation()
@@ -73,7 +75,10 @@ const AppliedEducationContent = ({
if (value) onSwitchWorkspace(value)
}}
>
<SelectTrigger className="h-12! w-fit max-w-full min-w-[280px] cursor-pointer justify-between rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3! py-1.5! hover:bg-state-base-hover">
<SelectTrigger
className="h-12! w-fit max-w-full min-w-[280px] cursor-pointer justify-between rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3! py-1.5! hover:bg-state-base-hover"
disabled={isSwitchingWorkspace}
>
<span className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-components-icon-bg-blue-solid text-[14px]">
<span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">
@@ -6,7 +6,7 @@ import type { ICurrentWorkspace } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
@@ -50,7 +50,6 @@ const EducationApplyAgeContent = () => {
const { handleEducationDiscount } = useEducationDiscount()
const router = useRouter()
const openAsyncWindow = useAsyncWindowOpen()
const queryClient = useQueryClient()
const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions())
const setEducationVerifying = useSetEducationVerifying()
@@ -115,12 +114,7 @@ const EducationApplyAgeContent = () => {
try {
await switchWorkspaceMutation.mutateAsync({ body: { tenant_id: tenantId } })
await Promise.all([
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() }),
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.get.queryKey() }),
])
onPlanInfoChanged()
updateEducationStatus()
globalThis.location.reload()
} catch {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
}
@@ -211,6 +205,7 @@ const EducationApplyAgeContent = () => {
currentWorkspace={currentWorkspace}
plan={plan.type}
action={renderAppliedEducationAction()}
isSwitchingWorkspace={switchWorkspaceMutation.isPending}
onSwitchWorkspace={(value) => {
void handleSwitchWorkspace(value)
}}
@@ -303,6 +298,7 @@ type AppliedEducationWorkspaceBlockProps = {
currentWorkspace: ICurrentWorkspace
plan: PlanType
action: ReactNode
isSwitchingWorkspace: boolean
onSwitchWorkspace: (tenantId: string) => void
}
@@ -310,6 +306,7 @@ function AppliedEducationWorkspaceContent({
currentWorkspace,
plan,
action,
isSwitchingWorkspace,
onSwitchWorkspace,
}: AppliedEducationWorkspaceBlockProps) {
const { data: workspacesData } = useQuery(consoleQuery.workspaces.get.queryOptions())
@@ -321,6 +318,7 @@ function AppliedEducationWorkspaceContent({
currentWorkspace={currentWorkspace}
plan={plan}
action={action}
isSwitchingWorkspace={isSwitchingWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
/>
)
@@ -153,6 +153,32 @@ vi.mock('@/service/client', () => ({
...options,
}),
},
rbac: {
myPermissions: {
get: {
queryOptions: () => ({
queryKey: ['current-permissions'],
queryFn: async () => {
if (mockPermissionKeysState.isPending) return new Promise(() => {})
return {
workspace: {
permission_keys: mockPermissionKeysState.permissionKeys,
},
app: {
default_permission_keys: [],
overrides: [],
},
dataset: {
default_permission_keys: [],
overrides: [],
},
}
},
}),
},
},
},
},
},
version: {
@@ -354,24 +380,6 @@ describe('Console bootstrap', () => {
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}`))
+4 -7
View File
@@ -2,15 +2,12 @@
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { workspacePermissionKeysQueryOptions } from '@/service/access-control/use-permission-keys'
import { consoleQuery } from '@/service/client'
import { emptyWorkspacePermissionKeys } from './app-context-normalizers'
import { currentWorkspaceIdAtom } from './workspace-state'
const workspacePermissionKeysQueryAtom = atomWithQuery((get) => {
const workspaceId = get(currentWorkspaceIdAtom)
return workspacePermissionKeysQueryOptions(workspaceId)
})
const workspacePermissionKeysQueryAtom = atomWithQuery(() =>
consoleQuery.workspaces.current.rbac.myPermissions.get.queryOptions(),
)
export const workspacePermissionKeysAtom = atom((get) => {
return (
@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { useCanManageAgents } from '../permissions'
function PermissionProbe() {
return <span>{String(useCanManageAgents())}</span>
}
describe('useCanManageAgents', () => {
it.each([
[['agent.manage'], 'true'],
[['dataset.create_and_management'], 'false'],
])('resolves agent.manage from the current permission snapshot', (permissionKeys, expected) => {
const { wrapper } = createConsoleQueryWrapper({ workspacePermissionKeys: permissionKeys })
render(<PermissionProbe />, { wrapper })
expect(screen.getByText(expected)).toBeInTheDocument()
})
})
-18
View File
@@ -206,24 +206,6 @@ export type UpdateRolesOfMemberRequest = {
roleIds: string[]
}
type WorkspacePermissionKeys = {
permission_keys: string[]
}
type ResourcePermissionKeys = {
default_permission_keys: string[]
overrides: Array<{
resource_id: string
permission_keys: string[]
}>
}
export type PermissionKeysResponse = {
workspace: WorkspacePermissionKeys
app: ResourcePermissionKeys
dataset: ResourcePermissionKeys
}
export type GetMembersOfRoleRequest = {
roleId: string
} & PaginationParameters
@@ -1,18 +0,0 @@
import type { PermissionKeysResponse } from '@/models/access-control'
import { queryOptions } from '@tanstack/react-query'
// oxlint-disable-next-line no-restricted-imports
import { get } from '../base'
const NAME_SPACE = 'workspace-permission-keys'
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),
})
}
+1 -1
View File
@@ -173,7 +173,7 @@ export const createConsoleQueryWrapper = (
}
if (options.workspacePermissionKeys !== null) {
if (options.workspacePermissionKeys)
seedWorkspacePermissionsQuery(queryClient, 'workspace-1', options.workspacePermissionKeys)
seedWorkspacePermissionsQuery(queryClient, options.workspacePermissionKeys)
else ensureWorkspacePermissionsQuery(queryClient)
}
const systemFeatures =
+12 -9
View File
@@ -1,10 +1,14 @@
import type { MyPermissionsResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import type { PermissionKeysResponse } from '@/models/access-control'
import { workspacePermissionKeysQueryOptions } from '@/service/access-control/use-permission-keys'
const currentWorkspacePermissionsQueryKey = [
['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'],
{ type: 'query' },
] as const
const createWorkspacePermissionsFixture = (
permissionKeys: readonly string[] = [],
): PermissionKeysResponse => ({
): MyPermissionsResponse => ({
workspace: {
permission_keys: [...permissionKeys],
},
@@ -20,23 +24,22 @@ const createWorkspacePermissionsFixture = (
export const seedWorkspacePermissionsQuery = (
queryClient: QueryClient,
workspaceId = 'workspace-1',
permissionKeys: readonly string[] = [],
) => {
const data = createWorkspacePermissionsFixture(permissionKeys)
queryClient.setQueryData(workspacePermissionKeysQueryOptions(workspaceId).queryKey, data)
queryClient.setQueryData(currentWorkspacePermissionsQueryKey, data)
return data
}
export const ensureWorkspacePermissionsQuery = (
queryClient: QueryClient,
workspaceId = 'workspace-1',
permissionKeys: readonly string[] = [],
) => {
const queryKey = workspacePermissionKeysQueryOptions(workspaceId).queryKey
const existingPermissions = queryClient.getQueryData<PermissionKeysResponse>(queryKey)
const existingPermissions = queryClient.getQueryData<MyPermissionsResponse>(
currentWorkspacePermissionsQueryKey,
)
if (existingPermissions === undefined)
return seedWorkspacePermissionsQuery(queryClient, workspaceId, permissionKeys)
return seedWorkspacePermissionsQuery(queryClient, permissionKeys)
return existingPermissions
}