diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index deed65d3..7e78010d 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -252,6 +252,20 @@ describe('SkillDetailPage', () => { expect(html).not.toContain('__RATING_WIDGET__') }) + it('does not render access denied content for logged-in forbidden responses', () => { + useSkillDetailMock.mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + error: new Error('HTTP 403'), + }) + + const html = renderToStaticMarkup() + + expect(html).toBe('') + expect(html).not.toContain('skillDetail.accessDenied') + }) + it('renders rejected owner preview without pending-review copy', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 990396b4..2911420b 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -240,7 +240,17 @@ export function SkillDetailPage() { const hideMutation = useMutation({ mutationFn: () => adminApi.hideSkill(skill!.id), - onSuccess: refreshSkill, + onSuccess: () => { + // After hiding, this detail route can become inaccessible for current viewer context. + // Navigate away immediately to avoid landing on an access-denied state. + queryClient.invalidateQueries({ queryKey: ['skills'] }) + const returnTo = normalizeSkillDetailReturnTo(search.returnTo) + if (returnTo) { + void navigate({ to: returnTo, replace: true }) + return + } + void navigate({ to: '/search', search: getSkillSquareSearch(), replace: true }) + }, }) const unhideMutation = useMutation({ @@ -392,6 +402,9 @@ export function SkillDetailPage() { const isLastVersion = versions?.length === 1 const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW' const canRereleaseVersion = (status?: string) => status === 'PUBLISHED' + const isForbiddenError = skillError instanceof ApiError + ? skillError.status === 403 + : skillError instanceof Error && skillError.message.includes('403') const isNotFoundError = skillError instanceof ApiError ? skillError.status === 400 || skillError.status === 404 || skillError.serverMessageKey === 'skill.not_found' : false @@ -403,6 +416,18 @@ export function SkillDetailPage() { clearDeletedSkillQueries(queryClient, namespace, slug, skill?.id) }, [isNotFoundError, namespace, queryClient, skill?.id, slug]) + useEffect(() => { + if (!isForbiddenError || !user) { + return + } + const returnTo = normalizeSkillDetailReturnTo(search.returnTo) + if (returnTo) { + void navigate({ to: returnTo, replace: true }) + return + } + void navigate({ to: '/search', search: getSkillSquareSearch(), replace: true }) + }, [isForbiddenError, navigate, search.returnTo, user]) + const metadataDiffEntries = (() => { const source = parseMetadataJson(diffSourceDetail?.parsedMetadataJson) const compare = parseMetadataJson(diffCompareDetail?.parsedMetadataJson) @@ -610,9 +635,7 @@ export function SkillDetailPage() { } if (skillError) { - const isForbidden = skillError instanceof Error && skillError.message.includes('403') - - if (isForbidden && !user) { + if (isForbiddenError && !user) { return (

{t('skillDetail.loginRequired')}

@@ -622,6 +645,10 @@ export function SkillDetailPage() { ) } + if (isForbiddenError) { + return null + } + if (isNotFoundError) { return (