Roadmap/#1379 admin ui doc level access (#1624)

* feat: adds document level access endpoints so admin ui can now accurately reflect document level access control
* chore(docs): new doc access callout, updates useDocumentInfo props from change
This commit is contained in:
Jarrod Flesch
2022-12-14 16:14:29 -05:00
committed by GitHub
parent d9c45f62b1
commit eda6f70acb
23 changed files with 702 additions and 154 deletions

View File

@@ -54,10 +54,10 @@ const {
### useFormFields ### useFormFields
There are times when a custom field component needs to have access to data from other fields, and you have a few options to do so. The `useFormFields` hook is a powerful and highly performant way to retrieve a form's field state, as well as to retrieve the `dispatchFields` method, which can be helpful for setting other fields' form states from anywhere within a form. There are times when a custom field component needs to have access to data from other fields, and you have a few options to do so. The `useFormFields` hook is a powerful and highly performant way to retrieve a form's field state, as well as to retrieve the `dispatchFields` method, which can be helpful for setting other fields' form states from anywhere within a form.
<Banner type="success"> <Banner type="success">
<strong>This hook is great for retrieving only certain fields from form state</strong> because it ensures that it will only cause a rerender when the items that you ask for change. <strong>This hook is great for retrieving only certain fields from form state</strong> because it ensures that it will only cause a rerender when the items that you ask for change.
</Banner> </Banner>
Thanks to the awesome package [`use-context-selector`](https://github.com/dai-shi/use-context-selector), you can retrieve a specific field's state easily. This is ideal because you can ensure you have an up-to-date field state, and your component will only re-render when _that field's state_ changes. Thanks to the awesome package [`use-context-selector`](https://github.com/dai-shi/use-context-selector), you can retrieve a specific field's state easily. This is ideal because you can ensure you have an up-to-date field state, and your component will only re-render when _that field's state_ changes.
@@ -84,7 +84,7 @@ const MyComponent: React.FC = () => {
### useAllFormFields ### useAllFormFields
**To retrieve more than one field**, you can use the `useAllFormFields` hook. Your component will re-render when _any_ field changes, so use this hook only if you absolutely need to. Unlike the `useFormFields` hook, this hook does not accept a "selector", and it always returns an array with type of `[fields: Fields, dispatch: React.Dispatch<Action>]]`. **To retrieve more than one field**, you can use the `useAllFormFields` hook. Your component will re-render when _any_ field changes, so use this hook only if you absolutely need to. Unlike the `useFormFields` hook, this hook does not accept a "selector", and it always returns an array with type of `[fields: Fields, dispatch: React.Dispatch<Action>]]`.
You can do lots of powerful stuff by retrieving the full form state, like using built-in helper functions to reduce field state to values only, or to retrieve sibling data by path. You can do lots of powerful stuff by retrieving the full form state, like using built-in helper functions to reduce field state to values only, or to retrieve sibling data by path.
@@ -100,7 +100,7 @@ const ExampleComponent: React.FC = () => {
// The result below will reflect the data stored in the form at the given time // The result below will reflect the data stored in the form at the given time
const formData = reduceFieldsToValues(fields, true); const formData = reduceFieldsToValues(fields, true);
// Pass in field state and a path, // Pass in field state and a path,
// and you will be sent all sibling data of the path that you've specified // and you will be sent all sibling data of the path that you've specified
const siblingData = getSiblingData(fields, 'someFieldName'); const siblingData = getSiblingData(fields, 'someFieldName');
@@ -135,7 +135,7 @@ The `useForm` hook can be used to interact with the form itself, and sends back
<Banner type="warning"> <Banner type="warning">
<strong>Warning:</strong><br/> <strong>Warning:</strong><br/>
This hook is optimized to avoid causing rerenders when fields change, and as such, its `fields` property will be out of date. You should only leverage this hook if you need to perform actions against the form in response to your users' actions. Do not rely on its returned "fields" as being up-to-date. They will be removed from this hook's response in an upcoming version. This hook is optimized to avoid causing rerenders when fields change, and as such, its `fields` property will be out of date. You should only leverage this hook if you need to perform actions against the form in response to your users' actions. Do not rely on its returned "fields" as being up-to-date. They will be removed from this hook's response in an upcoming version.
</Banner> </Banner>
The `useForm` hook returns an object with the following properties: The `useForm` hook returns an object with the following properties:
@@ -162,17 +162,19 @@ The `useForm` hook returns an object with the following properties:
The `useDocumentInfo` hook provides lots of information about the document currently being edited, including the following: The `useDocumentInfo` hook provides lots of information about the document currently being edited, including the following:
| Property | Description | | Property | Description |
|---------------------------|------------------------------------------------------------------------------------| |---------------------------|--------------------------------------------------------------------------------------------------------------------| |
| **`collection`** | If the doc is a collection, its collection config will be returned | | **`collection`** | If the doc is a collection, its collection config will be returned |
| **`global`** | If the doc is a global, its global config will be returned | | **`global`** | If the doc is a global, its global config will be returned |
| **`type`** | The type of document being edited (collection or global) | | **`type`** | The type of document being edited (collection or global) |
| **`id`** | If the doc is a collection, its ID will be returned | | **`id`** | If the doc is a collection, its ID will be returned |
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences | | **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences |
| **`versions`** | Versions of the current doc | | **`versions`** | Versions of the current doc |
| **`unpublishedVersions`** | Unpublished versions of the current doc | | **`unpublishedVersions`** | Unpublished versions of the current doc |
| **`publishedDoc`** | The currently published version of the doc being edited | | **`publishedDoc`** | The currently published version of the doc being edited |
| **`getVersions`** | Method to trigger the retrieval of document versions | | **`getVersions`** | Method to trigger the retrieval of document versions |
| **`docPermissions`** | The current documents permissions. Collection document permissions fallback when no id is present (i.e. on create) |
| **`getDocPermissions`** | Method to trigger the retrieval of document level permissions |
**Example:** **Example:**
@@ -250,7 +252,7 @@ const Greeting: React.FC = () => {
### useConfig ### useConfig
Used to easily fetch the full Payload config. Used to easily fetch the full Payload config.
```tsx ```tsx
import { useConfig } from 'payload/components/utilities'; import { useConfig } from 'payload/components/utilities';

View File

@@ -66,6 +66,8 @@ query {
} }
``` ```
Document access can also be queried on a collection/global basis. Access on a global can queried like `http://localhost:3000/api/global-slug/access`, Collection document access can be queried like `http://localhost:3000/api/collection-slug/access/:id`.
### Me ### Me
Returns either a logged in user with token or null when there is no logged in user. Returns either a logged in user with token or null when there is no logged in user.

View File

@@ -5,12 +5,13 @@ import qs from 'qs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useConfig } from '../Config'; import { useConfig } from '../Config';
import { PaginatedDocs } from '../../../../mongoose/types'; import { PaginatedDocs } from '../../../../mongoose/types';
import { ContextType, Props, Version } from './types'; import { ContextType, DocumentPermissions, EntityType, Props, Version } from './types';
import { TypeWithID } from '../../../../globals/config/types'; import { TypeWithID } from '../../../../globals/config/types';
import { TypeWithTimestamps } from '../../../../collections/config/types'; import { TypeWithTimestamps } from '../../../../collections/config/types';
import { Where } from '../../../../types'; import { Where } from '../../../../types';
import { DocumentPreferences } from '../../../../preferences/types'; import { DocumentPreferences } from '../../../../preferences/types';
import { usePreferences } from '../Preferences'; import { usePreferences } from '../Preferences';
import { useAuth } from '../Auth';
const Context = createContext({} as ContextType); const Context = createContext({} as ContextType);
@@ -23,24 +24,29 @@ export const DocumentInfoProvider: React.FC<Props> = ({
const { serverURL, routes: { api } } = useConfig(); const { serverURL, routes: { api } } = useConfig();
const { getPreference } = usePreferences(); const { getPreference } = usePreferences();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const { permissions } = useAuth();
const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null); const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null);
const [versions, setVersions] = useState<PaginatedDocs<Version>>(null); const [versions, setVersions] = useState<PaginatedDocs<Version>>(null);
const [unpublishedVersions, setUnpublishedVersions] = useState<PaginatedDocs<Version>>(null); const [unpublishedVersions, setUnpublishedVersions] = useState<PaginatedDocs<Version>>(null);
const [docPermissions, setDocPermissions] = useState<DocumentPermissions>(null);
const baseURL = `${serverURL}${api}`; const baseURL = `${serverURL}${api}`;
let slug; let slug: string;
let type; let type: EntityType;
let preferencesKey; let pluralType: 'globals' | 'collections';
let preferencesKey: string;
if (global) { if (global) {
slug = global.slug; slug = global.slug;
type = 'global'; type = 'global';
pluralType = 'globals';
preferencesKey = `global-${slug}`; preferencesKey = `global-${slug}`;
} }
if (collection) { if (collection) {
slug = collection.slug; slug = collection.slug;
type = 'collection'; type = 'collection';
pluralType = 'collections';
if (id) { if (id) {
preferencesKey = `collection-${slug}-${id}`; preferencesKey = `collection-${slug}-${id}`;
@@ -169,6 +175,25 @@ export const DocumentInfoProvider: React.FC<Props> = ({
} }
}, [i18n, global, collection, id, baseURL]); }, [i18n, global, collection, id, baseURL]);
const getDocPermissions = React.useCallback(async () => {
let docAccessURL: string;
if (pluralType === 'globals') {
docAccessURL = `/globals/${slug}/access`;
} else if (pluralType === 'collections' && id) {
docAccessURL = `/${slug}/access/${id}`;
}
if (docAccessURL) {
const res = await fetch(`${serverURL}${api}${docAccessURL}`);
const json = await res.json();
setDocPermissions(json);
} else {
// fallback to permissions from the collection
// (i.e. create has no id)
setDocPermissions(permissions[pluralType][slug]);
}
}, [serverURL, api, pluralType, slug, id, permissions]);
useEffect(() => { useEffect(() => {
getVersions(); getVersions();
}, [getVersions]); }, [getVersions]);
@@ -181,6 +206,10 @@ export const DocumentInfoProvider: React.FC<Props> = ({
getDocPreferences(); getDocPreferences();
}, [getPreference, preferencesKey]); }, [getPreference, preferencesKey]);
useEffect(() => {
getDocPermissions();
}, [getDocPermissions]);
const value = { const value = {
slug, slug,
type, type,
@@ -192,6 +221,8 @@ export const DocumentInfoProvider: React.FC<Props> = ({
getVersions, getVersions,
publishedDoc, publishedDoc,
id, id,
getDocPermissions,
docPermissions,
}; };
return ( return (
@@ -202,5 +233,3 @@ export const DocumentInfoProvider: React.FC<Props> = ({
}; };
export const useDocumentInfo = (): ContextType => useContext(Context); export const useDocumentInfo = (): ContextType => useContext(Context);
export default Context;

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { CollectionPermission, GlobalPermission } from '../../../../auth';
import { SanitizedCollectionConfig, TypeWithID, TypeWithTimestamps } from '../../../../collections/config/types'; import { SanitizedCollectionConfig, TypeWithID, TypeWithTimestamps } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types'; import { SanitizedGlobalConfig } from '../../../../globals/config/types';
import { PaginatedDocs } from '../../../../mongoose/types'; import { PaginatedDocs } from '../../../../mongoose/types';
@@ -6,10 +7,14 @@ import { TypeWithVersion } from '../../../../versions/types';
export type Version = TypeWithVersion<any> export type Version = TypeWithVersion<any>
export type DocumentPermissions = null | GlobalPermission | CollectionPermission
export type EntityType = 'global' | 'collection'
export type ContextType = { export type ContextType = {
collection?: SanitizedCollectionConfig collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig global?: SanitizedGlobalConfig
type: 'global' | 'collection' type: EntityType
/** Slug of the collection or global */ /** Slug of the collection or global */
slug?: string slug?: string
id?: string | number id?: string | number
@@ -18,6 +23,8 @@ export type ContextType = {
unpublishedVersions?: PaginatedDocs<Version> unpublishedVersions?: PaginatedDocs<Version>
publishedDoc?: TypeWithID & TypeWithTimestamps & { _status?: string } publishedDoc?: TypeWithID & TypeWithTimestamps & { _status?: string }
getVersions: () => Promise<void> getVersions: () => Promise<void>
docPermissions: DocumentPermissions
getDocPermissions: () => Promise<void>
} }
export type Props = { export type Props = {

View File

@@ -20,9 +20,9 @@ const GlobalView: React.FC<IndexProps> = (props) => {
const { state: locationState } = useLocation<{data?: Record<string, unknown>}>(); const { state: locationState } = useLocation<{data?: Record<string, unknown>}>();
const locale = useLocale(); const locale = useLocale();
const { setStepNav } = useStepNav(); const { setStepNav } = useStepNav();
const { permissions, user } = useAuth(); const { user } = useAuth();
const [initialState, setInitialState] = useState<Fields>(); const [initialState, setInitialState] = useState<Fields>();
const { getVersions, preferencesKey } = useDocumentInfo(); const { getVersions, preferencesKey, docPermissions, getDocPermissions } = useDocumentInfo();
const { getPreference } = usePreferences(); const { getPreference } = usePreferences();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -50,9 +50,10 @@ const GlobalView: React.FC<IndexProps> = (props) => {
const onSave = useCallback(async (json) => { const onSave = useCallback(async (json) => {
getVersions(); getVersions();
getDocPermissions();
const state = await buildStateFromSchema({ fieldSchema: fields, data: json.result, operation: 'update', user, locale, t }); const state = await buildStateFromSchema({ fieldSchema: fields, data: json.result, operation: 'update', user, locale, t });
setInitialState(state); setInitialState(state);
}, [getVersions, fields, user, locale, t]); }, [getVersions, fields, user, locale, t, getDocPermissions]);
const [{ data }] = usePayloadAPI( const [{ data }] = usePayloadAPI(
`${serverURL}${api}/globals/${slug}`, `${serverURL}${api}/globals/${slug}`,
@@ -79,16 +80,14 @@ const GlobalView: React.FC<IndexProps> = (props) => {
awaitInitialState(); awaitInitialState();
}, [dataToRender, fields, user, locale, getPreference, preferencesKey, t]); }, [dataToRender, fields, user, locale, getPreference, preferencesKey, t]);
const globalPermissions = permissions?.globals?.[slug];
return ( return (
<RenderCustomComponent <RenderCustomComponent
DefaultComponent={DefaultGlobal} DefaultComponent={DefaultGlobal}
CustomComponent={CustomEdit} CustomComponent={CustomEdit}
componentProps={{ componentProps={{
isLoading: !initialState, isLoading: !initialState || !docPermissions,
data: dataToRender, data: dataToRender,
permissions: globalPermissions, permissions: docPermissions,
initialState, initialState,
global, global,
onSave, onSave,

View File

@@ -15,6 +15,7 @@ import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { Fields } from '../../../forms/Form/types'; import { Fields } from '../../../forms/Form/types';
import { usePreferences } from '../../../utilities/Preferences'; import { usePreferences } from '../../../utilities/Preferences';
import { EditDepthContext } from '../../../utilities/EditDepth'; import { EditDepthContext } from '../../../utilities/EditDepth';
import { CollectionPermission } from '../../../../../auth';
const EditView: React.FC<IndexProps> = (props) => { const EditView: React.FC<IndexProps> = (props) => {
const { collection: incomingCollection, isEditing } = props; const { collection: incomingCollection, isEditing } = props;
@@ -40,20 +41,21 @@ const EditView: React.FC<IndexProps> = (props) => {
const { state: locationState } = useLocation(); const { state: locationState } = useLocation();
const history = useHistory(); const history = useHistory();
const [initialState, setInitialState] = useState<Fields>(); const [initialState, setInitialState] = useState<Fields>();
const { permissions, user } = useAuth(); const { user } = useAuth();
const { getVersions, preferencesKey } = useDocumentInfo(); const { getVersions, preferencesKey, getDocPermissions, docPermissions } = useDocumentInfo();
const { getPreference } = usePreferences(); const { getPreference } = usePreferences();
const { t } = useTranslation('general'); const { t } = useTranslation('general');
const onSave = useCallback(async (json: any) => { const onSave = useCallback(async (json: any) => {
getVersions(); getVersions();
getDocPermissions();
if (!isEditing) { if (!isEditing) {
setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
} else { } else {
const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale, t }); const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale, t });
setInitialState(state); setInitialState(state);
} }
}, [admin, collection, isEditing, getVersions, user, id, t, locale]); }, [admin, collection, isEditing, getVersions, user, id, t, locale, getDocPermissions]);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI( const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null), (isEditing ? `${serverURL}${api}/${slug}/${id}` : null),
@@ -87,10 +89,9 @@ const EditView: React.FC<IndexProps> = (props) => {
); );
} }
const collectionPermissions = permissions?.collections?.[slug];
const apiURL = `${serverURL}${api}/${slug}/${id}${collection.versions.drafts ? '?draft=true' : ''}`; const apiURL = `${serverURL}${api}/${slug}/${id}${collection.versions.drafts ? '?draft=true' : ''}`;
const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`; const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`;
const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission); const hasSavePermission = (isEditing && docPermissions?.update?.permission) || (!isEditing && (docPermissions as CollectionPermission)?.create?.permission);
return ( return (
<EditDepthContext.Provider value={1}> <EditDepthContext.Provider value={1}>
@@ -99,10 +100,10 @@ const EditView: React.FC<IndexProps> = (props) => {
CustomComponent={CustomEdit} CustomComponent={CustomEdit}
componentProps={{ componentProps={{
id, id,
isLoading: !initialState, isLoading: !initialState || !docPermissions,
data: dataToRender, data: dataToRender,
collection, collection,
permissions: collectionPermissions, permissions: docPermissions,
isEditing, isEditing,
onSave, onSave,
initialState, initialState,

View File

@@ -1,9 +1,10 @@
import { PayloadRequest } from '../../express/types'; import type { PayloadRequest } from '../../express/types';
import { Permissions } from '../types'; import type { Permissions } from '../types';
import type { AllOperations } from '../../types';
import { adminInit as adminInitTelemetry } from '../../utilities/telemetry/events/adminInit'; import { adminInit as adminInitTelemetry } from '../../utilities/telemetry/events/adminInit';
import { tabHasName } from '../../fields/config/types'; import { getEntityPolicies } from '../../utilities/getEntityPolicies';
const allOperations = ['create', 'read', 'update', 'delete']; const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete'];
type Arguments = { type Arguments = {
req: PayloadRequest req: PayloadRequest
@@ -28,75 +29,6 @@ async function accessOperation(args: Arguments): Promise<Permissions> {
const isLoggedIn = !!(user); const isLoggedIn = !!(user);
const userCollectionConfig = (user && user.collection) ? config.collections.find((collection) => collection.slug === user.collection) : null; const userCollectionConfig = (user && user.collection) ? config.collections.find((collection) => collection.slug === user.collection) : null;
const createAccessPromise = async (obj, access, operation, disableWhere = false) => {
const updatedObj = obj;
const result = await access({ req });
if (typeof result === 'object' && !disableWhere) {
updatedObj[operation] = {
permission: true,
where: result,
};
} else if (updatedObj[operation]?.permission !== false) {
updatedObj[operation] = {
permission: !!(result),
};
}
};
const executeFieldPolicies = (obj, fields, operation) => {
const updatedObj = obj;
fields.forEach(async (field) => {
if (field.name) {
if (!updatedObj[field.name]) updatedObj[field.name] = {};
if (field.access && typeof field.access[operation] === 'function') {
promises.push(createAccessPromise(updatedObj[field.name], field.access[operation], operation, true));
} else {
updatedObj[field.name][operation] = {
permission: isLoggedIn,
};
}
if (field.fields) {
if (!updatedObj[field.name].fields) updatedObj[field.name].fields = {};
executeFieldPolicies(updatedObj[field.name].fields, field.fields, operation);
}
} else if (field.fields) {
executeFieldPolicies(updatedObj, field.fields, operation);
} else if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
if (!updatedObj[tab.name]) updatedObj[tab.name] = { fields: {} };
executeFieldPolicies(updatedObj[tab.name].fields, tab.fields, operation);
} else {
executeFieldPolicies(updatedObj, tab.fields, operation);
}
});
}
});
};
const executeEntityPolicies = async (entity, operations, type) => {
if (!results[type]) results[type] = {};
results[type][entity.slug] = {
fields: {},
};
operations.forEach((operation) => {
executeFieldPolicies(results[type][entity.slug].fields, entity.fields, operation);
if (typeof entity.access[operation] === 'function') {
promises.push(createAccessPromise(results[type][entity.slug], entity.access[operation], operation));
} else {
results[type][entity.slug][operation] = {
permission: isLoggedIn,
};
}
});
};
if (userCollectionConfig) { if (userCollectionConfig) {
results.canAccessAdmin = userCollectionConfig.access.admin ? await userCollectionConfig.access.admin(args) : isLoggedIn; results.canAccessAdmin = userCollectionConfig.access.admin ? await userCollectionConfig.access.admin(args) : isLoggedIn;
@@ -115,16 +47,37 @@ async function accessOperation(args: Arguments): Promise<Permissions> {
collectionOperations.push('readVersions'); collectionOperations.push('readVersions');
} }
executeEntityPolicies(collection, collectionOperations, 'collections'); const [collectionPolicy, collectionPromises] = getEntityPolicies({
type: 'collection',
req,
entity: collection,
operations: collectionOperations,
});
results.collections = {
...results.collections,
[collection.slug]: collectionPolicy,
};
promises.push(...collectionPromises);
}); });
config.globals.forEach((global) => { config.globals.forEach((global) => {
const globalOperations = ['read', 'update']; const globalOperations: AllOperations[] = ['read', 'update'];
if (global.versions) { if (global.versions) {
globalOperations.push('readVersions'); globalOperations.push('readVersions');
} }
executeEntityPolicies(global, globalOperations, 'globals');
const [globalPolicy, globalPromises] = getEntityPolicies({
type: 'global',
req,
entity: global,
operations: globalOperations,
});
results.globals = {
...results.globals,
[global.slug]: globalPolicy,
};
promises.push(...globalPromises);
}); });
await Promise.all(promises); await Promise.all(promises);

View File

@@ -29,6 +29,7 @@ export type CollectionPermission = {
read: Permission read: Permission
update: Permission update: Permission
delete: Permission delete: Permission
readVersions?: Permission
fields: { fields: {
[field: string]: FieldPermissions [field: string]: FieldPermissions
} }
@@ -37,6 +38,7 @@ export type CollectionPermission = {
export type GlobalPermission = { export type GlobalPermission = {
read: Permission read: Permission
update: Permission update: Permission
readVersions?: Permission
fields: { fields: {
[field: string]: FieldPermissions [field: string]: FieldPermissions
} }
@@ -44,8 +46,12 @@ export type GlobalPermission = {
export type Permissions = { export type Permissions = {
canAccessAdmin: boolean canAccessAdmin: boolean
collections: CollectionPermission[] collections: {
globals?: GlobalPermission[] [collectionSlug: string]: CollectionPermission
}
globals?: {
[globalSlug: string]: GlobalPermission
}
} }
export type User = { export type User = {

View File

@@ -18,6 +18,7 @@ import deleteHandler from './requestHandlers/delete';
import findByID from './requestHandlers/findByID'; import findByID from './requestHandlers/findByID';
import update, { deprecatedUpdate } from './requestHandlers/update'; import update, { deprecatedUpdate } from './requestHandlers/update';
import logoutHandler from '../auth/requestHandlers/logout'; import logoutHandler from '../auth/requestHandlers/logout';
import docAccessRequestHandler from './requestHandlers/docAccess';
const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => { const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => {
let { endpoints } = collection; let { endpoints } = collection;
@@ -119,6 +120,11 @@ const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => {
method: 'post', method: 'post',
handler: create, handler: create,
}, },
{
path: '/access/:id',
method: 'get',
handler: docAccessRequestHandler,
},
{ {
path: '/:id', path: '/:id',
method: 'put', method: 'put',

View File

@@ -35,6 +35,8 @@ import buildWhereInputType from '../../graphql/schema/buildWhereInputType';
import getDeleteResolver from './resolvers/delete'; import getDeleteResolver from './resolvers/delete';
import { toWords, formatNames } from '../../utilities/formatLabels'; import { toWords, formatNames } from '../../utilities/formatLabels';
import { Collection, SanitizedCollectionConfig } from '../config/types'; import { Collection, SanitizedCollectionConfig } from '../config/types';
import { buildPolicyType } from '../../graphql/schema/buildPoliciesType';
import { docAccessResolver } from './resolvers/docAccess';
function initCollectionsGraphQL(payload: Payload): void { function initCollectionsGraphQL(payload: Payload): void {
Object.keys(payload.collections).forEach((slug) => { Object.keys(payload.collections).forEach((slug) => {
@@ -181,6 +183,19 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: findResolver(collection), resolve: findResolver(collection),
}; };
payload.Query.fields[`docAccess${singularName}`] = {
type: buildPolicyType({
typeSuffix: 'DocAccess',
entity: collection.config,
type: 'collection',
scope: 'docAccess',
}),
args: {
id: { type: new GraphQLNonNull(idType) },
},
resolve: docAccessResolver(),
};
payload.Mutation.fields[`create${singularName}`] = { payload.Mutation.fields[`create${singularName}`] = {
type: collection.graphQL.type, type: collection.graphQL.type,
args: { args: {

View File

@@ -0,0 +1,25 @@
import { CollectionPermission, GlobalPermission } from '../../../auth';
import { PayloadRequest } from '../../../express/types';
import { docAccess } from '../../operations/docAccess';
export type Resolver = (
_: unknown,
args: {
id: string | number
},
context: {
req: PayloadRequest,
res: Response
}
) => Promise<CollectionPermission | GlobalPermission>
export function docAccessResolver(): Resolver {
async function resolver(_, args, context) {
return docAccess({
req: context.req,
id: args.id,
});
}
return resolver;
}

View File

@@ -0,0 +1,45 @@
import { AllOperations } from '../../types';
import { CollectionPermission } from '../../auth';
import type { PayloadRequest } from '../../express/types';
import { getEntityPolicies } from '../../utilities/getEntityPolicies';
const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete'];
type Arguments = {
req: PayloadRequest
id: string
}
export async function docAccess(args: Arguments): Promise<CollectionPermission> {
const {
id,
req,
req: {
collection: {
config,
},
},
} = args;
const collectionOperations = [...allOperations];
if (config.auth && (typeof config.auth.maxLoginAttempts !== 'undefined' && config.auth.maxLoginAttempts !== 0)) {
collectionOperations.push('unlock');
}
if (config.versions) {
collectionOperations.push('readVersions');
}
const [policy, promises] = getEntityPolicies({
type: 'collection',
req,
entity: config,
operations: collectionOperations,
id,
});
await Promise.all(promises);
return policy;
}

View File

@@ -0,0 +1,18 @@
import { Response, NextFunction } from 'express';
import httpStatus from 'http-status';
import { PayloadRequest } from '../../express/types';
import { docAccess } from '../operations/docAccess';
import { CollectionPermission, GlobalPermission } from '../../auth';
export default async function docAccessRequestHandler(req: PayloadRequest, res: Response, next: NextFunction): Promise<Response<CollectionPermission | GlobalPermission> | void> {
try {
const accessResults = await docAccess({
req,
id: req.params.id,
});
return res.status(httpStatus.OK).json(accessResults);
} catch (error) {
return next(error);
}
}

View File

@@ -5,9 +5,10 @@ import restoreVersion from './requestHandlers/restoreVersion';
import { SanitizedGlobalConfig } from './config/types'; import { SanitizedGlobalConfig } from './config/types';
import update from './requestHandlers/update'; import update from './requestHandlers/update';
import findOne from './requestHandlers/findOne'; import findOne from './requestHandlers/findOne';
import docAccessRequestHandler from './requestHandlers/docAccess';
const buildEndpoints = (global: SanitizedGlobalConfig): Endpoint[] => { const buildEndpoints = (global: SanitizedGlobalConfig): Endpoint[] => {
const { endpoints } = global; const { endpoints, slug } = global;
if (global.versions) { if (global.versions) {
endpoints.push(...[ endpoints.push(...[
@@ -30,6 +31,11 @@ const buildEndpoints = (global: SanitizedGlobalConfig): Endpoint[] => {
} }
endpoints.push(...[ endpoints.push(...[
{
path: '/access',
method: 'get',
handler: async (req, res, next) => docAccessRequestHandler(req, res, next, global),
},
{ {
path: '/', path: '/',
method: 'get', method: 'get',

View File

@@ -16,11 +16,13 @@ import buildWhereInputType from '../../graphql/schema/buildWhereInputType';
import { Field } from '../../fields/config/types'; import { Field } from '../../fields/config/types';
import { toWords } from '../../utilities/formatLabels'; import { toWords } from '../../utilities/formatLabels';
import { SanitizedGlobalConfig } from '../config/types'; import { SanitizedGlobalConfig } from '../config/types';
import { buildPolicyType } from '../../graphql/schema/buildPoliciesType';
import { docAccessResolver } from './resolvers/docAccess';
function initGlobalsGraphQL(payload: Payload): void { function initGlobalsGraphQL(payload: Payload): void {
if (payload.config.globals) { if (payload.config.globals) {
Object.keys(payload.globals.config).forEach((slug) => { Object.keys(payload.globals.config).forEach((slug) => {
const global = payload.globals.config[slug]; const global: SanitizedGlobalConfig = payload.globals.config[slug];
const { const {
fields, fields,
versions, versions,
@@ -71,6 +73,16 @@ function initGlobalsGraphQL(payload: Payload): void {
resolve: updateResolver(global), resolve: updateResolver(global),
}; };
payload.Query.fields[`docAccess${formattedName}`] = {
type: buildPolicyType({
typeSuffix: 'DocAccess',
entity: global,
type: 'global',
scope: 'docAccess',
}),
resolve: docAccessResolver(global),
};
if (global.versions) { if (global.versions) {
const versionGlobalFields: Field[] = [ const versionGlobalFields: Field[] = [
...buildVersionGlobalFields(global), ...buildVersionGlobalFields(global),

View File

@@ -0,0 +1,23 @@
import { CollectionPermission, GlobalPermission } from '../../../auth';
import { PayloadRequest } from '../../../express/types';
import { SanitizedGlobalConfig } from '../../config/types';
import { docAccess } from '../../operations/docAccess';
export type Resolver = (
_: unknown,
context: {
req: PayloadRequest,
res: Response
}
) => Promise<CollectionPermission | GlobalPermission>
export function docAccessResolver(global: SanitizedGlobalConfig): Resolver {
async function resolver(_, context) {
return docAccess({
req: context.req,
globalConfig: global,
});
}
return resolver;
}

View File

@@ -0,0 +1,34 @@
import { AllOperations } from '../../types';
import { GlobalPermission } from '../../auth';
import type { PayloadRequest } from '../../express/types';
import { getEntityPolicies } from '../../utilities/getEntityPolicies';
import { SanitizedGlobalConfig } from '../config/types';
type Arguments = {
req: PayloadRequest
globalConfig: SanitizedGlobalConfig
}
export async function docAccess(args: Arguments): Promise<GlobalPermission> {
const {
req,
globalConfig,
} = args;
const globalOperations: AllOperations[] = ['read', 'update'];
if (globalConfig.versions) {
globalOperations.push('readVersions');
}
const [policy, promises] = getEntityPolicies({
type: 'global',
req,
entity: globalConfig,
operations: globalOperations,
});
await Promise.all(promises);
return policy;
}

View File

@@ -0,0 +1,19 @@
import { Response, NextFunction } from 'express';
import httpStatus from 'http-status';
import { PayloadRequest } from '../../express/types';
import { docAccess } from '../operations/docAccess';
import { CollectionPermission, GlobalPermission } from '../../auth';
import { SanitizedGlobalConfig } from '../config/types';
export default async function docAccessRequestHandler(req: PayloadRequest, res: Response, next: NextFunction, globalConfig: SanitizedGlobalConfig): Promise<Response<CollectionPermission | GlobalPermission> | void> {
try {
const accessResults = await docAccess({
req,
globalConfig,
});
return res.status(httpStatus.OK).json(accessResults);
} catch (error) {
return next(error);
}
}

View File

@@ -2,14 +2,16 @@
import { GraphQLJSONObject } from 'graphql-type-json'; import { GraphQLJSONObject } from 'graphql-type-json';
import { GraphQLBoolean, GraphQLNonNull, GraphQLObjectType } from 'graphql'; import { GraphQLBoolean, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import formatName from '../utilities/formatName'; import formatName from '../utilities/formatName';
import { SanitizedCollectionConfig } from '../../collections/config/types'; import { CollectionConfig, SanitizedCollectionConfig } from '../../collections/config/types';
import { SanitizedGlobalConfig } from '../../globals/config/types'; import { GlobalConfig, SanitizedGlobalConfig } from '../../globals/config/types';
import { Field } from '../../fields/config/types'; import { Field } from '../../fields/config/types';
import { Payload } from '../..'; import { Payload } from '../..';
import { toWords } from '../../utilities/formatLabels'; import { toWords } from '../../utilities/formatLabels';
type OperationType = 'create' | 'read' | 'update' | 'delete' | 'unlock' | 'readVersions'; type OperationType = 'create' | 'read' | 'update' | 'delete' | 'unlock' | 'readVersions';
type AccessScopes = 'docAccess' | undefined
type ObjectTypeFields = { type ObjectTypeFields = {
[key in OperationType | 'fields']?: { type: GraphQLObjectType }; [key in OperationType | 'fields']?: { type: GraphQLObjectType };
} }
@@ -78,24 +80,31 @@ const buildFields = (label, fieldsToBuild) => fieldsToBuild.reduce((builtFields,
return builtFields; return builtFields;
}, {}); }, {});
const buildEntity = (name: string, entityFields: Field[], operations: OperationType[]) => { type BuildEntityPolicy = {
const formattedName = toWords(name, true); name: string
entityFields: Field[]
operations: OperationType[]
scope: AccessScopes
}
export const buildEntityPolicy = (args: BuildEntityPolicy) => {
const { name, entityFields, operations, scope } = args;
const fieldsTypeName = toWords(`${name}-${scope || ''}-Fields`, true);
const fields = { const fields = {
fields: { fields: {
type: new GraphQLObjectType({ type: new GraphQLObjectType({
name: formatName(`${formattedName}Fields`), name: fieldsTypeName,
fields: buildFields(`${formattedName}Fields`, entityFields), fields: buildFields(fieldsTypeName, entityFields),
}), }),
}, },
}; };
operations.forEach((operation) => { operations.forEach((operation) => {
const capitalizedOperation = operation.charAt(0).toUpperCase() + operation.slice(1); const operationTypeName = toWords(`${name}-${operation}-${scope || 'Access'}`, true);
fields[operation] = { fields[operation] = {
type: new GraphQLObjectType({ type: new GraphQLObjectType({
name: `${formattedName}${capitalizedOperation}Access`, name: operationTypeName,
fields: { fields: {
permission: { type: new GraphQLNonNull(GraphQLBoolean) }, permission: { type: new GraphQLNonNull(GraphQLBoolean) },
where: { type: GraphQLJSONObject }, where: { type: GraphQLJSONObject },
@@ -107,6 +116,66 @@ const buildEntity = (name: string, entityFields: Field[], operations: OperationT
return fields; return fields;
}; };
type BuildPolicyType = {
typeSuffix?: string
scope?: AccessScopes
} & ({
entity: CollectionConfig
type: 'collection'
} | {
entity: GlobalConfig
type: 'global'
})
export function buildPolicyType(args: BuildPolicyType): GraphQLObjectType {
const { typeSuffix, entity, type, scope } = args;
const { slug } = entity;
let operations = [];
if (type === 'collection') {
operations = ['create', 'read', 'update', 'delete'];
if (entity.auth && (typeof entity.auth === 'object' && typeof entity.auth.maxLoginAttempts !== 'undefined' && entity.auth.maxLoginAttempts !== 0)) {
operations.push('unlock');
}
if (entity.versions) {
operations.push('readVersions');
}
const collectionTypeName = formatName(`${slug}${typeSuffix || ''}`);
return new GraphQLObjectType({
name: collectionTypeName,
fields: buildEntityPolicy({
name: slug,
entityFields: entity.fields,
operations,
scope,
}),
});
}
// else create global type
operations = ['read', 'update'];
if (entity.versions) {
operations.push('readVersions');
}
const globalTypeName = formatName(`${global?.graphQL?.name || slug}${typeSuffix || ''}`);
return new GraphQLObjectType({
name: globalTypeName,
fields: buildEntityPolicy({
name: entity?.graphQL?.name || slug,
entityFields: entity.fields,
operations,
scope,
}),
});
}
export default function buildPoliciesType(payload: Payload): GraphQLObjectType { export default function buildPoliciesType(payload: Payload): GraphQLObjectType {
const fields = { const fields = {
canAccessAdmin: { canAccessAdmin: {
@@ -115,36 +184,26 @@ export default function buildPoliciesType(payload: Payload): GraphQLObjectType {
}; };
Object.values(payload.config.collections).forEach((collection: SanitizedCollectionConfig) => { Object.values(payload.config.collections).forEach((collection: SanitizedCollectionConfig) => {
const collectionOperations: OperationType[] = ['create', 'read', 'update', 'delete']; const collectionPolicyType = buildPolicyType({
typeSuffix: 'Access',
if (collection.auth && (typeof collection.auth.maxLoginAttempts !== 'undefined' && collection.auth.maxLoginAttempts !== 0)) { entity: collection,
collectionOperations.push('unlock'); type: 'collection',
} });
if (collection.versions) {
collectionOperations.push('readVersions');
}
fields[formatName(collection.slug)] = { fields[formatName(collection.slug)] = {
type: new GraphQLObjectType({ type: collectionPolicyType,
name: formatName(`${collection.slug}Access`),
fields: buildEntity(collection.slug, collection.fields, collectionOperations),
}),
}; };
}); });
Object.values(payload.config.globals).forEach((global: SanitizedGlobalConfig) => { Object.values(payload.config.globals).forEach((global: SanitizedGlobalConfig) => {
const globalOperations: OperationType[] = ['read', 'update']; const globalPolicyType = buildPolicyType({
typeSuffix: 'Access',
if (global.versions) { entity: global,
globalOperations.push('readVersions'); type: 'global',
} });
fields[formatName(global.slug)] = { fields[formatName(global.slug)] = {
type: new GraphQLObjectType({ type: globalPolicyType,
name: formatName(`${global?.graphQL?.name || global.slug}Access`),
fields: buildEntity(global?.graphQL?.name || global.slug, global.fields, globalOperations),
}),
}; };
}); });

View File

@@ -38,6 +38,9 @@ export interface PayloadMongooseDocument extends MongooseDocument {
} }
export type Operation = 'create' | 'read' | 'update' | 'delete'; export type Operation = 'create' | 'read' | 'update' | 'delete';
export type VersionOperations = 'readVersions';
export type AuthOperations = 'unlock';
export type AllOperations = Operation | VersionOperations | AuthOperations;
export function docHasTimestamps(doc: any): doc is TypeWithTimestamps { export function docHasTimestamps(doc: any): doc is TypeWithTimestamps {
return doc?.createdAt && doc?.updatedAt; return doc?.createdAt && doc?.updatedAt;

View File

@@ -0,0 +1,196 @@
import { Access } from '../config/types';
import { AllOperations, Where, Document } from '../types';
import { FieldAccess, tabHasName } from '../fields/config/types';
import type { CollectionConfig } from '../collections/config/types';
import type { GlobalConfig } from '../globals/config/types';
import type { PayloadRequest } from '../express/types';
import type { CollectionPermission, GlobalPermission } from '../auth/types';
import { TypeWithID } from '../collections/config/types';
type Args = ({
req: PayloadRequest
operations: AllOperations[]
id?: string
} & ({
type: 'collection'
entity: CollectionConfig
} | {
type: 'global'
entity: GlobalConfig
}))
type ReturnType<T extends Args> = T['type'] extends 'global' ? [GlobalPermission, Promise<void>[]] : [CollectionPermission, Promise<void>[]]
type CreateAccessPromise = (args: {
accessLevel: 'entity' | 'field',
policiesObj: {
[key: string]: any
}
access: Access | FieldAccess,
operation: AllOperations,
disableWhere?: boolean,
}) => Promise<void>
export function getEntityPolicies<T extends Args>(args: T): ReturnType<T> {
const { req, entity, operations, id, type } = args;
const isLoggedIn = !!(req.user);
// ---- ---- ---- ---- ---- ---- ---- ---- ----
// `policies` and `promises` get mutated in
// the functions below, and return in the end
// ---- ---- ---- ---- ---- ---- ---- ---- ----
const policies = {
fields: {},
} as ReturnType<T>[0];
const promises = [] as ReturnType<T>[1];
let docBeingAccessed;
async function getEntityDoc({ where }: { where?: Where } = {}): Promise<TypeWithID & Document> {
if (entity.slug) {
if (type === 'global') {
return req.payload.findGlobal({
overrideAccess: true,
slug: entity.slug,
});
}
if (type === 'collection' && id) {
if (typeof where === 'object') {
const paginatedRes = await req.payload.find({
overrideAccess: true,
collection: entity.slug,
where: {
...where,
and: [
...where.and || [],
{
id: {
equals: id,
},
},
],
},
limit: 1,
});
return paginatedRes?.docs?.[0] || undefined;
}
return req.payload.findByID({
overrideAccess: true,
collection: entity.slug,
id,
});
}
}
return undefined;
}
const createAccessPromise: CreateAccessPromise = async ({
policiesObj,
access,
operation,
disableWhere = false,
accessLevel,
}) => {
const mutablePolicies = policiesObj;
if (accessLevel === 'field' && docBeingAccessed === undefined) {
docBeingAccessed = await getEntityDoc();
}
const accessResult = await access({ req, id, doc: docBeingAccessed });
if (typeof accessResult === 'object' && !disableWhere) {
mutablePolicies[operation] = {
permission: !!(await getEntityDoc({ where: accessResult })),
where: accessResult,
};
} else if (mutablePolicies[operation]?.permission !== false) {
mutablePolicies[operation] = {
permission: !!(accessResult),
};
}
};
const executeFieldPolicies = ({
policiesObj = {},
fields,
operation,
}) => {
const mutablePolicies = policiesObj;
fields.forEach((field) => {
if (field.name) {
if (!mutablePolicies[field.name]) mutablePolicies[field.name] = {};
if (field.access && typeof field.access[operation] === 'function') {
promises.push(createAccessPromise({
policiesObj: mutablePolicies[field.name],
access: field.access[operation],
operation,
disableWhere: true,
accessLevel: 'field',
}));
} else {
mutablePolicies[field.name][operation] = {
permission: isLoggedIn,
};
}
if (field.fields) {
if (!mutablePolicies[field.name].fields) mutablePolicies[field.name].fields = {};
executeFieldPolicies({
policiesObj: mutablePolicies[field.name].fields,
fields: field.fields,
operation,
});
}
} else if (field.fields) {
executeFieldPolicies({
policiesObj: mutablePolicies,
fields: field.fields,
operation,
});
} else if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
if (!mutablePolicies[tab.name]) mutablePolicies[tab.name] = { fields: {} };
executeFieldPolicies({
policiesObj: mutablePolicies[tab.name].fields,
fields: tab.fields,
operation,
});
} else {
executeFieldPolicies({
policiesObj: mutablePolicies,
fields: tab.fields,
operation,
});
}
});
}
});
};
operations.forEach((operation) => {
executeFieldPolicies({
policiesObj: policies.fields,
fields: entity.fields,
operation,
});
if (typeof entity.access[operation] === 'function') {
promises.push(createAccessPromise({
policiesObj: policies,
access: entity.access[operation],
operation,
accessLevel: 'entity',
}));
} else {
policies[operation] = {
permission: isLoggedIn,
};
}
});
return [policies, promises] as ReturnType<T>;
}

View File

@@ -10,6 +10,7 @@ export const restrictedSlug = 'restricted';
export const restrictedVersionsSlug = 'restricted-versions'; export const restrictedVersionsSlug = 'restricted-versions';
export const siblingDataSlug = 'sibling-data'; export const siblingDataSlug = 'sibling-data';
export const relyOnRequestHeadersSlug = 'rely-on-request-headers'; export const relyOnRequestHeadersSlug = 'rely-on-request-headers';
export const docLevelAccessSlug = 'doc-level-access';
const openAccess = { const openAccess = {
create: () => true, create: () => true,
@@ -32,7 +33,7 @@ const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => {
export default buildConfig({ export default buildConfig({
admin: { admin: {
user: 'users' user: 'users',
}, },
collections: [ collections: [
{ {
@@ -45,7 +46,7 @@ export default buildConfig({
setTimeout(resolve, 50, true); // set to 'true' or 'false' here to simulate the response setTimeout(resolve, 50, true); // set to 'true' or 'false' here to simulate the response
}), }),
}, },
fields: [] fields: [],
}, },
{ {
slug, slug,
@@ -195,6 +196,52 @@ export default buildConfig({
}, },
], ],
}, },
{
slug: docLevelAccessSlug,
labels: {
singular: 'Doc Level Access',
plural: 'Doc Level Access',
},
access: {
delete: () => ({
and: [
{
approvedForRemoval: {
equals: true,
},
},
],
}),
},
fields: [
{
name: 'approvedForRemoval',
type: 'checkbox',
defaultValue: false,
admin: {
position: 'sidebar',
},
},
{
name: 'approvedTitle',
type: 'text',
localized: true,
access: {
update: (args) => {
if (args?.doc?.lockTitle) {
return false;
}
return true;
},
},
},
{
name: 'lockTitle',
type: 'checkbox',
defaultValue: false,
},
],
},
], ],
onInit: async (payload) => { onInit: async (payload) => {
await payload.create({ await payload.create({

View File

@@ -4,7 +4,7 @@ import payload from '../../src';
import { AdminUrlUtil } from '../helpers/adminUrlUtil'; import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers'; import { initPayloadE2E } from '../helpers/configHelpers';
import { login } from '../helpers'; import { login } from '../helpers';
import { restrictedVersionsSlug, readOnlySlug, restrictedSlug, slug } from './config'; import { restrictedVersionsSlug, readOnlySlug, restrictedSlug, slug, docLevelAccessSlug } from './config';
import type { ReadOnlyCollection, RestrictedVersion } from './payload-types'; import type { ReadOnlyCollection, RestrictedVersion } from './payload-types';
/** /**
@@ -17,16 +17,17 @@ import type { ReadOnlyCollection, RestrictedVersion } from './payload-types';
*/ */
const { beforeAll, describe } = test; const { beforeAll, describe } = test;
describe('access control', () => { describe('access control', () => {
let page: Page; let page: Page;
let url: AdminUrlUtil; let url: AdminUrlUtil;
let restrictedUrl: AdminUrlUtil; let restrictedUrl: AdminUrlUtil;
let readOnlyUrl: AdminUrlUtil; let readOnlyUrl: AdminUrlUtil;
let restrictedVersionsUrl: AdminUrlUtil; let restrictedVersionsUrl: AdminUrlUtil;
let serverURL: string;
beforeAll(async ({ browser }) => { beforeAll(async ({ browser }) => {
const { serverURL } = await initPayloadE2E(__dirname); const config = await initPayloadE2E(__dirname);
serverURL = config.serverURL;
url = new AdminUrlUtil(serverURL, slug); url = new AdminUrlUtil(serverURL, slug);
restrictedUrl = new AdminUrlUtil(serverURL, restrictedSlug); restrictedUrl = new AdminUrlUtil(serverURL, restrictedSlug);
@@ -174,11 +175,51 @@ describe('access control', () => {
await expect(page.locator('.versions-count')).not.toBeVisible(); await expect(page.locator('.versions-count')).not.toBeVisible();
}); });
}); });
describe('doc level access', () => {
let existingDoc: ReadOnlyCollection;
let docLevelAccessURL;
beforeAll(async () => {
docLevelAccessURL = new AdminUrlUtil(serverURL, docLevelAccessSlug);
existingDoc = await payload.create<any>({
collection: docLevelAccessSlug,
data: {
approvedTitle: 'Title',
lockTitle: true,
approvedForRemoval: false,
},
});
});
test('disable field based on document data', async () => {
await page.goto(docLevelAccessURL.edit(existingDoc.id));
// validate that the text input is disabled because the field is "locked"
const isDisabled = await page.locator('#field-approvedTitle').isDisabled();
expect(isDisabled).toBe(true);
});
test('disable operation based on document data', async () => {
await page.goto(docLevelAccessURL.edit(existingDoc.id));
// validate that the delete action is not displayed
const duplicateAction = page.locator('.collection-edit__collection-actions >> li').last();
await expect(duplicateAction).toContainText('Duplicate');
await page.locator('#field-approvedForRemoval + button').click();
await page.locator('#action-save').click();
const deleteAction = page.locator('.collection-edit__collection-actions >> li').last();
await expect(deleteAction).toContainText('Delete');
});
});
}); });
async function createDoc(data: any): Promise<{ id: string }> { async function createDoc(data: any): Promise<{ id: string }> {
return payload.create({ return payload.create({
collection: slug, collection: docLevelAccessSlug,
data, data,
}); });
} }