Skip to content

Commit e833fe6

Browse files
authored
fix: publish button incorrectly shown after saving draft when access denied (#15319)
### What Fixes publish and unpublish buttons incorrectly appearing for users without publish permissions after saving a draft. ### Why The document access endpoints weren't passing request body data to the permission operations. This caused the simulated `_status` field (used to check "can this user publish?") to be ignored. The frontend simulates `_status: 'published'` to determine if the publish button should be shown, but the backend would check permissions against the actual draft document instead, incorrectly returning `true`. This made the publish button visible even though clicking it would still fail with an access error. Additionally, the unpublish button didn't check for publish permissions at all - if a user can't publish, they shouldn't be able to unpublish either. ### How - Pass `data: req.data` in collection and global `docAccess` endpoint handlers so simulated status is used for permission checks - Update `useGetDocPermissions` to extract actual document from API response (`data?.doc || data || {}`) before sending to backend, ensuring field permissions receive complete document structure - Add `hasPublishPermission` check to `UnpublishButton` component Fixes #15312
1 parent 4181a12 commit e833fe6

File tree

5 files changed

+93
-19
lines changed

5 files changed

+93
-19
lines changed

packages/payload/src/collections/endpoints/docAccess.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const docAccessHandler: PayloadHandler = async (req) => {
1212
const result = await docAccessOperation({
1313
id,
1414
collection,
15+
data: req.data,
1516
req,
1617
})
1718

packages/payload/src/globals/endpoints/docAccess.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { docAccessOperation } from '../operations/docAccess.js'
99
export const docAccessHandler: PayloadHandler = async (req) => {
1010
const globalConfig = getRequestGlobal(req)
1111
const result = await docAccessOperation({
12+
data: req.data,
1213
globalConfig,
1314
req,
1415
})

packages/ui/src/elements/UnpublishButton/index.tsx

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function UnpublishButton() {
2323
data: dataFromProps,
2424
globalSlug,
2525
hasPublishedDoc,
26+
hasPublishPermission,
2627
incrementVersionCount,
2728
isTrashed,
2829
setHasPublishedDoc,
@@ -57,7 +58,7 @@ export function UnpublishButton() {
5758

5859
const unpublish = useCallback(
5960
(unpublishAll?: boolean) => {
60-
; (async () => {
61+
;(async () => {
6162
let url
6263
let method
6364

@@ -147,7 +148,7 @@ export function UnpublishButton() {
147148
setHasLocalizedFields(hasLocalizedField)
148149
}, [entityConfig?.fields])
149150

150-
const canUnpublish = hasPublishedDoc && !isTrashed
151+
const canUnpublish = hasPublishPermission && hasPublishedDoc && !isTrashed
151152
const canUnpublishCurrentLocale = hasLocalizedFields && canUnpublish
152153

153154
return (
@@ -166,21 +167,21 @@ export function UnpublishButton() {
166167
SubMenuPopupContent={
167168
canUnpublishCurrentLocale
168169
? ({ close }) => {
169-
return (
170-
<PopupList.ButtonGroup>
171-
<PopupList.Button
172-
id="action-unpublish-locale"
173-
onClick={() => {
174-
setUnpublishAll(false)
175-
toggleModal(unPublishModalSlug)
176-
close()
177-
}}
178-
>
179-
{t('version:unpublishIn', { locale: getTranslation(localeLabel, i18n) })}
180-
</PopupList.Button>
181-
</PopupList.ButtonGroup>
182-
)
183-
}
170+
return (
171+
<PopupList.ButtonGroup>
172+
<PopupList.Button
173+
id="action-unpublish-locale"
174+
onClick={() => {
175+
setUnpublishAll(false)
176+
toggleModal(unPublishModalSlug)
177+
close()
178+
}}
179+
>
180+
{t('version:unpublishIn', { locale: getTranslation(localeLabel, i18n) })}
181+
</PopupList.Button>
182+
</PopupList.ButtonGroup>
183+
)
184+
}
184185
: undefined
185186
}
186187
type="button"

packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const useGetDocPermissions = ({
5656
}),
5757
{
5858
body: JSON.stringify({
59-
...(data || {}),
59+
...(data?.doc || data || {}),
6060
_status: 'draft',
6161
}),
6262
credentials: 'include',
@@ -77,7 +77,7 @@ export const useGetDocPermissions = ({
7777
}),
7878
{
7979
body: JSON.stringify({
80-
...(data || {}),
80+
...(data?.doc || data || {}),
8181
_status: 'published',
8282
}),
8383
credentials: 'include',

test/versions/e2e.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,43 @@ describe('Versions', () => {
748748
await expect(page.locator('#action-save')).not.toBeAttached()
749749
})
750750

751+
test('collections — should keep publish button hidden after saving draft when access control prevents update', async () => {
752+
const draftDoc = await payload.create({
753+
collection: disablePublishSlug,
754+
data: {
755+
_status: 'draft',
756+
title: 'draft title',
757+
},
758+
})
759+
760+
await page.goto(disablePublishURL.edit(String(draftDoc.id)))
761+
762+
// Verify publish button is hidden on initial load
763+
await expect(page.locator('#action-save')).not.toBeAttached()
764+
765+
await page.locator('#field-title').fill('updated title')
766+
await saveDocAndAssert(page, '#action-save-draft')
767+
768+
// Verify publish button is still hidden after saving as draft
769+
await expect(page.locator('#action-save')).not.toBeAttached()
770+
})
771+
772+
test('collections — should hide unpublish button when access control prevents update', async () => {
773+
const publishedDoc = await payload.create({
774+
collection: disablePublishSlug,
775+
data: {
776+
_status: 'published',
777+
title: 'title',
778+
},
779+
overrideAccess: true,
780+
})
781+
782+
await page.goto(disablePublishURL.edit(String(publishedDoc.id)))
783+
784+
// Verify unpublish button is hidden when user doesn't have publish permission
785+
await expect(page.locator('#action-unpublish')).not.toBeAttached()
786+
})
787+
751788
test('collections — should show custom error message when unpublishing fails', async () => {
752789
const publishedDoc = await payload.create({
753790
collection: errorOnUnpublishSlug,
@@ -1102,6 +1139,40 @@ describe('Versions', () => {
11021139
await expect(page.locator('#action-save')).not.toBeAttached()
11031140
})
11041141

1142+
test('globals — should keep publish button hidden after saving draft when access control prevents update', async () => {
1143+
const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug)
1144+
await page.goto(url.global(disablePublishGlobalSlug))
1145+
1146+
// Verify publish button is hidden on initial load
1147+
await expect(page.locator('#action-save')).not.toBeAttached()
1148+
1149+
// Update the title and save as draft
1150+
await page.locator('#field-title').fill('updated global title')
1151+
await saveDocAndAssert(page, '#action-save-draft')
1152+
1153+
// Verify publish button is still hidden after saving as draft
1154+
// This is the key regression test - before the fix, the button would appear after save
1155+
await expect(page.locator('#action-save')).not.toBeAttached()
1156+
})
1157+
1158+
test('globals — should hide unpublish button when access control prevents update', async () => {
1159+
// Then publish it with override access to create a published version
1160+
await payload.updateGlobal({
1161+
slug: disablePublishGlobalSlug,
1162+
data: {
1163+
_status: 'published',
1164+
title: 'published global',
1165+
},
1166+
overrideAccess: true,
1167+
})
1168+
1169+
const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug)
1170+
await page.goto(url.global(disablePublishGlobalSlug))
1171+
1172+
// Verify unpublish button is hidden when user doesn't have publish permission
1173+
await expect(page.locator('#action-unpublish')).not.toBeAttached()
1174+
})
1175+
11051176
test('global — should show versions drawer when SelectComparison more option is clicked', async () => {
11061177
await payload.updateGlobal({
11071178
slug: draftGlobalSlug,

0 commit comments

Comments
 (0)