Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: hide publish button based on permissions #4203

Merged
merged 9 commits into from
Nov 20, 2023
60 changes: 57 additions & 3 deletions packages/payload/src/admin/components/elements/Publish/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import qs from 'qs'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'

import { useForm, useFormModified } from '../../forms/Form/context'
import FormSubmit from '../../forms/Submit'
import { useConfig } from '../../utilities/Config'
import { useDocumentInfo } from '../../utilities/DocumentInfo'
import { useLocale } from '../../utilities/Locale'
import RenderCustomComponent from '../../utilities/RenderCustomComponent'

export type CustomPublishButtonProps = React.ComponentType<
Expand All @@ -12,17 +15,21 @@ export type CustomPublishButtonProps = React.ComponentType<
}
>
export type DefaultPublishButtonProps = {
canPublish: boolean
disabled: boolean
id?: string
label: string
publish: () => void
}
const DefaultPublishButton: React.FC<DefaultPublishButtonProps> = ({
id,
canPublish,
disabled,
label,
publish,
}) => {
if (!canPublish) return null

return (
<FormSubmit buttonId={id} disabled={disabled} onClick={publish} size="small" type="button">
{label}
Expand All @@ -35,29 +42,76 @@ type Props = {
}

export const Publish: React.FC<Props> = ({ CustomComponent }) => {
const { publishedDoc, unpublishedVersions } = useDocumentInfo()
const { submit } = useForm()
const { code } = useLocale()
const { id, collection, global, publishedDoc, unpublishedVersions } = useDocumentInfo()
const [hasPublishPermission, setHasPublishPermission] = React.useState(false)
const { getData, submit } = useForm()
const modified = useFormModified()
const {
routes: { api },
serverURL,
} = useConfig()
const { t } = useTranslation('version')

const hasNewerVersions = unpublishedVersions?.totalDocs > 0
const canPublish = modified || hasNewerVersions || !publishedDoc

const publish = useCallback(() => {
submit({
void submit({
overrides: {
_status: 'published',
},
})
}, [submit])

React.useEffect(() => {
const fetchPublishAccess = async () => {
let docAccessURL: string
let operation = 'update'

const params = {
locale: code || undefined,
}
if (global) {
docAccessURL = `/globals/${global.slug}/access`
} else if (collection) {
if (!id) operation = 'create'
docAccessURL = `/${collection.slug}/access${id ? `/${id}` : ''}`
}

if (docAccessURL) {
const data = getData()

const res = await fetch(`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`, {
body: JSON.stringify({
...data,
_status: 'published',
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'post',
})
const json = await res.json()
const result = Boolean(json?.[operation]?.permission)
setHasPublishPermission(result)
} else {
setHasPublishPermission(true)
}
}

void fetchPublishAccess()
}, [api, code, collection, getData, global, id, serverURL])

return (
<RenderCustomComponent
CustomComponent={CustomComponent}
DefaultComponent={DefaultPublishButton}
componentProps={{
id: 'action-save',
DefaultButton: DefaultPublishButton,
canPublish: hasPublishPermission,
disabled: !canPublish,
label: t('publishChanges'),
publish,
Expand Down
10 changes: 10 additions & 0 deletions packages/payload/src/collections/buildEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => {
method: 'get',
path: '/access/:id',
},
{
handler: docAccessRequestHandler,
method: 'post',
path: '/access/:id',
},
{
handler: docAccessRequestHandler,
method: 'post',
path: '/access',
},
{
handler: deprecatedUpdate,
method: 'put',
Expand Down
5 changes: 5 additions & 0 deletions packages/payload/src/globals/buildEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const buildEndpoints = (global: SanitizedGlobalConfig): Endpoint[] => {
method: 'get',
path: '/access',
},
{
handler: async (req, res, next) => docAccessRequestHandler(req, res, next, global),
method: 'post',
path: '/access',
},
{
handler: findOne(global),
method: 'get',
Expand Down
5 changes: 4 additions & 1 deletion packages/payload/src/utilities/getEntityPolicies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ export async function getEntityPolicies<T extends Args>(args: T): Promise<Return
if (accessLevel === 'field' && docBeingAccessed === undefined) {
docBeingAccessed = await getEntityDoc()
}
const accessResult = await access({ id, doc: docBeingAccessed, req })

const data = req?.body

const accessResult = await access({ id, data, doc: docBeingAccessed, req })

if (typeof accessResult === 'object' && !disableWhere) {
mutablePolicies[operation] = {
Expand Down
30 changes: 30 additions & 0 deletions test/versions/collections/DisablePublish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'

import { disablePublishSlug } from '../slugs'

const DisablePublish: CollectionConfig = {
access: {
create: ({ data }) => {
return data._status !== 'published'
},
update: ({ data }) => {
return data._status !== 'published'
},
},
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
required: true,
type: 'text',
},
],
slug: disablePublishSlug,
versions: {
drafts: true,
},
}

export default DisablePublish
3 changes: 3 additions & 0 deletions test/versions/collections/Drafts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'

import { extractTranslations } from '../../../packages/payload/src/translations/extractTranslations'
import { CustomPublishButton } from '../elements/CustomSaveButton'
import { draftCollectionSlug } from '../slugs'

const labels = extractTranslations(['version:draft', 'version:published', 'version:status'])

const DraftPosts: CollectionConfig = {
access: {
read: ({ req: { user } }) => {
Expand Down
16 changes: 9 additions & 7 deletions test/versions/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,16 @@ import path from 'path'

import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
import AutosavePosts from './collections/Autosave'
import DisablePublish from './collections/DisablePublish'
import DraftPosts from './collections/Drafts'
import Posts from './collections/Posts'
import VersionPosts from './collections/Versions'
import AutosaveGlobal from './globals/Autosave'
import DisablePublishGlobal from './globals/DisablePublish'
import DraftGlobal from './globals/Draft'
import { clearAndSeedEverything } from './seed'

export default buildConfigWithDefaults({
collections: [Posts, AutosavePosts, DraftPosts, VersionPosts],
globals: [AutosaveGlobal, DraftGlobal],
indexSortableFields: true,
localization: {
defaultLocale: 'en',
locales: ['en', 'es'],
},
admin: {
webpack: (config) => ({
...config,
Expand All @@ -29,6 +24,13 @@ export default buildConfigWithDefaults({
},
}),
},
collections: [DisablePublish, Posts, AutosavePosts, DraftPosts, VersionPosts],
globals: [AutosaveGlobal, DraftGlobal, DisablePublishGlobal],
indexSortableFields: true,
localization: {
defaultLocale: 'en',
locales: ['en', 'es'],
},
onInit: async (payload) => {
await clearAndSeedEverything(payload)
},
Expand Down
32 changes: 32 additions & 0 deletions test/versions/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import { titleToDelete } from './shared'
import {
autoSaveGlobalSlug,
autosaveCollectionSlug,
disablePublishGlobalSlug,
disablePublishSlug,
draftCollectionSlug,
draftGlobalSlug,
} from './slugs'
Expand All @@ -55,6 +57,7 @@ describe('versions', () => {
let url: AdminUrlUtil
let serverURL: string
let autosaveURL: AdminUrlUtil
let disablePublishURL: AdminUrlUtil

beforeAll(async ({ browser }) => {
const config = await initPayloadE2E(__dirname)
Expand All @@ -73,6 +76,7 @@ describe('versions', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, draftCollectionSlug)
autosaveURL = new AdminUrlUtil(serverURL, autosaveCollectionSlug)
disablePublishURL = new AdminUrlUtil(serverURL, disablePublishSlug)
})

// This test has to run before bulk updates that will rename the title
Expand Down Expand Up @@ -345,5 +349,33 @@ describe('versions', () => {
await expect(page.locator('#field-title')).toHaveValue('first post title')
await expect(page.locator('#field-description')).toHaveValue('first post description')
})

test('should hide publish when access control prevents updating on globals', async () => {
const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug)
await page.goto(url.global(disablePublishGlobalSlug))

await expect(page.locator('#action-save')).not.toBeAttached()
})

test('should hide publish when access control prevents create operation', async () => {
await page.goto(disablePublishURL.create)

await expect(page.locator('#action-save')).not.toBeAttached()
})

test('should hide publish when access control prevents update operation', async () => {
const publishedDoc = await payload.create({
collection: disablePublishSlug,
data: {
_status: 'published',
title: 'title',
},
overrideAccess: true,
})

await page.goto(disablePublishURL.edit(String(publishedDoc.id)))

await expect(page.locator('#action-save')).not.toBeAttached()
})
})
})
23 changes: 23 additions & 0 deletions test/versions/globals/DisablePublish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'

import { disablePublishGlobalSlug } from '../slugs'

const DisablePublishGlobal: GlobalConfig = {
access: {
update: ({ data }) => {
return data._status !== 'published'
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
slug: disablePublishGlobalSlug,
versions: {
drafts: true,
},
}

export default DisablePublishGlobal
4 changes: 4 additions & 0 deletions test/versions/slugs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export const postCollectionSlug = 'posts' as const

export const versionCollectionSlug = 'version-posts' as const

export const disablePublishSlug = 'disable-publish' as const

export const disablePublishGlobalSlug = 'disable-publish-global' as const

export const collectionSlugs = [
autosaveCollectionSlug,
draftCollectionSlug,
Expand Down
Loading