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 (