From 1826fa67682dc7eb95df79bcd9a8e0bb9943eecd Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:24:55 +0800 Subject: [PATCH] fix(web): refresh owner permissions after rejected updates Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../publish-owner-permissions.test.tsx | 51 +++++++++++++++++++ web/src/pages/dashboard/publish.tsx | 6 +++ 2 files changed, 57 insertions(+) create mode 100644 web/src/pages/dashboard/publish-owner-permissions.test.tsx diff --git a/web/src/pages/dashboard/publish-owner-permissions.test.tsx b/web/src/pages/dashboard/publish-owner-permissions.test.tsx new file mode 100644 index 000000000..cbd239758 --- /dev/null +++ b/web/src/pages/dashboard/publish-owner-permissions.test.tsx @@ -0,0 +1,51 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ detail: vi.fn(), publish: vi.fn(), navigate: vi.fn() })) +vi.mock('@/api/client', async () => ({ + ...await vi.importActual('@/api/client'), + fetchJson: api.detail, + skillAvatarApi: { status: vi.fn().mockResolvedValue({ avatarUrl: '/active' }) }, +})) +vi.mock('@/shared/hooks/use-skill-queries', async () => ({ + ...await vi.importActual('@/shared/hooks/use-skill-queries'), + previewSkillPackage: vi.fn().mockResolvedValue({ name: 'foo', displayName: 'Foo', version: '1.1' }), + usePublishSkill: () => ({ mutateAsync: api.publish, isPending: false }), +})) +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => api.navigate, useSearch: () => ({ namespace: 'team', updateSkill: 'foo' }) })) +vi.mock('react-i18next', async () => ({ ...await vi.importActual('react-i18next'), useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useMyNamespaces: () => ({ data: [{ slug: 'team', displayName: 'Team' }], isLoading: false }) })) +vi.mock('@/features/publish/upload-zone', () => ({ UploadZone: ({ onFileSelect }: { onFileSelect: (file: File) => void }) => })) +vi.mock('@/features/skill/skill-avatar-manager', () => ({ SkillAvatarManager: () => null })) +vi.mock('@/features/skill/skill-avatar-editor', () => ({ KEEP_AVATAR: { action: 'KEEP', ready: true }, SkillAvatarEditor: () => null })) +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +import { PublishPage } from './publish' +import { ApiError } from '@/api/client' +import { toast } from '@/shared/lib/toast' + +afterEach(() => { cleanup(); vi.clearAllMocks() }) + +describe('package rejection refreshes current update permissions', () => { + it.each([true, false])('uses the server ownerReadOnly=%s response after a rejected package', async ownerReadOnly => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) + api.detail.mockResolvedValueOnce({ ownerReadOnly: false }).mockResolvedValue({ ownerReadOnly }) + api.publish.mockRejectedValue(new ApiError('Localized package rejection', 400)) + render() + await waitFor(() => expect(api.detail).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByText('choose package')) + const submit = screen.getByRole('button', { name: 'publish.confirm' }) + await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false)) + fireEvent.click(submit) + await waitFor(() => expect(toast.error).toHaveBeenCalled()) + expect(api.detail).toHaveBeenCalledTimes(2) + expect(api.publish).toHaveBeenCalledTimes(1) + expect(submit.hasAttribute('disabled')).toBe(ownerReadOnly) + expect(Boolean(screen.queryByText('mySkills.ownerReadOnly'))).toBe(ownerReadOnly) + expect(toast.success).not.toHaveBeenCalled() + expect(api.navigate).not.toHaveBeenCalled() + cleanup() + client.clear() + }) +}) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index ee037f165..f8cbd1c85 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -27,6 +27,7 @@ import { Card } from '@/shared/ui/card' import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from '@/features/skill/skill-avatar-editor' import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager' import { previewSkillPackage, usePublishSkill, useSkillDetail } from '@/shared/hooks/use-skill-queries' +import { getSkillDetailQueryKey } from '@/shared/hooks/query-keys' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' @@ -167,6 +168,11 @@ export function PublishPage() { navigate({ to: '/dashboard/skills' }) }) } catch (error) { + // A package rejection may also reflect changed membership. Re-read the + // server state instead of inferring permissions from its localized message. + if (isUpdate && !avatarOnly && error instanceof ApiError && error.status === 400) { + await queryClient.invalidateQueries({ queryKey: getSkillDetailQueryKey(namespaceSlug, updateSlug ?? '') }) + } // Avatar-only updates call the API directly rather than a mutation. if (avatarOnly) await refreshSkillPermissionsAfterForbidden(queryClient, error) if (error instanceof ApiError && error.status === 408) {