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:
@@ -54,10 +54,10 @@ const {
|
||||
|
||||
### 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">
|
||||
<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>
|
||||
|
||||
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
|
||||
|
||||
**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.
|
||||
|
||||
@@ -100,7 +100,7 @@ const ExampleComponent: React.FC = () => {
|
||||
// The result below will reflect the data stored in the form at the given time
|
||||
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
|
||||
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">
|
||||
<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>
|
||||
|
||||
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:
|
||||
|
||||
| Property | Description |
|
||||
|---------------------------|------------------------------------------------------------------------------------|
|
||||
| **`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 |
|
||||
| **`type`** | The type of document being edited (collection or global) |
|
||||
| **`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 |
|
||||
| **`versions`** | Versions of the current doc |
|
||||
| **`unpublishedVersions`** | Unpublished versions of the current doc |
|
||||
| **`publishedDoc`** | The currently published version of the doc being edited |
|
||||
| **`getVersions`** | Method to trigger the retrieval of document versions |
|
||||
| Property | Description |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------------------------------| |
|
||||
| **`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 |
|
||||
| **`type`** | The type of document being edited (collection or global) |
|
||||
| **`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 |
|
||||
| **`versions`** | Versions of the current doc |
|
||||
| **`unpublishedVersions`** | Unpublished versions of the current doc |
|
||||
| **`publishedDoc`** | The currently published version of the doc being edited |
|
||||
| **`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:**
|
||||
|
||||
@@ -250,7 +252,7 @@ const Greeting: React.FC = () => {
|
||||
|
||||
### useConfig
|
||||
|
||||
Used to easily fetch the full Payload config.
|
||||
Used to easily fetch the full Payload config.
|
||||
|
||||
```tsx
|
||||
import { useConfig } from 'payload/components/utilities';
|
||||
|
||||
@@ -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
|
||||
|
||||
Returns either a logged in user with token or null when there is no logged in user.
|
||||
|
||||
@@ -5,12 +5,13 @@ import qs from 'qs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConfig } from '../Config';
|
||||
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 { TypeWithTimestamps } from '../../../../collections/config/types';
|
||||
import { Where } from '../../../../types';
|
||||
import { DocumentPreferences } from '../../../../preferences/types';
|
||||
import { usePreferences } from '../Preferences';
|
||||
import { useAuth } from '../Auth';
|
||||
|
||||
const Context = createContext({} as ContextType);
|
||||
|
||||
@@ -23,24 +24,29 @@ export const DocumentInfoProvider: React.FC<Props> = ({
|
||||
const { serverURL, routes: { api } } = useConfig();
|
||||
const { getPreference } = usePreferences();
|
||||
const { i18n } = useTranslation();
|
||||
const { permissions } = useAuth();
|
||||
const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null);
|
||||
const [versions, setVersions] = useState<PaginatedDocs<Version>>(null);
|
||||
const [unpublishedVersions, setUnpublishedVersions] = useState<PaginatedDocs<Version>>(null);
|
||||
const [docPermissions, setDocPermissions] = useState<DocumentPermissions>(null);
|
||||
|
||||
const baseURL = `${serverURL}${api}`;
|
||||
let slug;
|
||||
let type;
|
||||
let preferencesKey;
|
||||
let slug: string;
|
||||
let type: EntityType;
|
||||
let pluralType: 'globals' | 'collections';
|
||||
let preferencesKey: string;
|
||||
|
||||
if (global) {
|
||||
slug = global.slug;
|
||||
type = 'global';
|
||||
pluralType = 'globals';
|
||||
preferencesKey = `global-${slug}`;
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
slug = collection.slug;
|
||||
type = 'collection';
|
||||
pluralType = 'collections';
|
||||
|
||||
if (id) {
|
||||
preferencesKey = `collection-${slug}-${id}`;
|
||||
@@ -169,6 +175,25 @@ export const DocumentInfoProvider: React.FC<Props> = ({
|
||||
}
|
||||
}, [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(() => {
|
||||
getVersions();
|
||||
}, [getVersions]);
|
||||
@@ -181,6 +206,10 @@ export const DocumentInfoProvider: React.FC<Props> = ({
|
||||
getDocPreferences();
|
||||
}, [getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
getDocPermissions();
|
||||
}, [getDocPermissions]);
|
||||
|
||||
const value = {
|
||||
slug,
|
||||
type,
|
||||
@@ -192,6 +221,8 @@ export const DocumentInfoProvider: React.FC<Props> = ({
|
||||
getVersions,
|
||||
publishedDoc,
|
||||
id,
|
||||
getDocPermissions,
|
||||
docPermissions,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -202,5 +233,3 @@ export const DocumentInfoProvider: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
export const useDocumentInfo = (): ContextType => useContext(Context);
|
||||
|
||||
export default Context;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { CollectionPermission, GlobalPermission } from '../../../../auth';
|
||||
import { SanitizedCollectionConfig, TypeWithID, TypeWithTimestamps } from '../../../../collections/config/types';
|
||||
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
|
||||
import { PaginatedDocs } from '../../../../mongoose/types';
|
||||
@@ -6,10 +7,14 @@ import { TypeWithVersion } from '../../../../versions/types';
|
||||
|
||||
export type Version = TypeWithVersion<any>
|
||||
|
||||
export type DocumentPermissions = null | GlobalPermission | CollectionPermission
|
||||
|
||||
export type EntityType = 'global' | 'collection'
|
||||
|
||||
export type ContextType = {
|
||||
collection?: SanitizedCollectionConfig
|
||||
global?: SanitizedGlobalConfig
|
||||
type: 'global' | 'collection'
|
||||
type: EntityType
|
||||
/** Slug of the collection or global */
|
||||
slug?: string
|
||||
id?: string | number
|
||||
@@ -18,6 +23,8 @@ export type ContextType = {
|
||||
unpublishedVersions?: PaginatedDocs<Version>
|
||||
publishedDoc?: TypeWithID & TypeWithTimestamps & { _status?: string }
|
||||
getVersions: () => Promise<void>
|
||||
docPermissions: DocumentPermissions
|
||||
getDocPermissions: () => Promise<void>
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
|
||||
@@ -20,9 +20,9 @@ const GlobalView: React.FC<IndexProps> = (props) => {
|
||||
const { state: locationState } = useLocation<{data?: Record<string, unknown>}>();
|
||||
const locale = useLocale();
|
||||
const { setStepNav } = useStepNav();
|
||||
const { permissions, user } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const [initialState, setInitialState] = useState<Fields>();
|
||||
const { getVersions, preferencesKey } = useDocumentInfo();
|
||||
const { getVersions, preferencesKey, docPermissions, getDocPermissions } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -50,9 +50,10 @@ const GlobalView: React.FC<IndexProps> = (props) => {
|
||||
|
||||
const onSave = useCallback(async (json) => {
|
||||
getVersions();
|
||||
getDocPermissions();
|
||||
const state = await buildStateFromSchema({ fieldSchema: fields, data: json.result, operation: 'update', user, locale, t });
|
||||
setInitialState(state);
|
||||
}, [getVersions, fields, user, locale, t]);
|
||||
}, [getVersions, fields, user, locale, t, getDocPermissions]);
|
||||
|
||||
const [{ data }] = usePayloadAPI(
|
||||
`${serverURL}${api}/globals/${slug}`,
|
||||
@@ -79,16 +80,14 @@ const GlobalView: React.FC<IndexProps> = (props) => {
|
||||
awaitInitialState();
|
||||
}, [dataToRender, fields, user, locale, getPreference, preferencesKey, t]);
|
||||
|
||||
const globalPermissions = permissions?.globals?.[slug];
|
||||
|
||||
return (
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultGlobal}
|
||||
CustomComponent={CustomEdit}
|
||||
componentProps={{
|
||||
isLoading: !initialState,
|
||||
isLoading: !initialState || !docPermissions,
|
||||
data: dataToRender,
|
||||
permissions: globalPermissions,
|
||||
permissions: docPermissions,
|
||||
initialState,
|
||||
global,
|
||||
onSave,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { Fields } from '../../../forms/Form/types';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { EditDepthContext } from '../../../utilities/EditDepth';
|
||||
import { CollectionPermission } from '../../../../../auth';
|
||||
|
||||
const EditView: React.FC<IndexProps> = (props) => {
|
||||
const { collection: incomingCollection, isEditing } = props;
|
||||
@@ -40,20 +41,21 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
const { state: locationState } = useLocation();
|
||||
const history = useHistory();
|
||||
const [initialState, setInitialState] = useState<Fields>();
|
||||
const { permissions, user } = useAuth();
|
||||
const { getVersions, preferencesKey } = useDocumentInfo();
|
||||
const { user } = useAuth();
|
||||
const { getVersions, preferencesKey, getDocPermissions, docPermissions } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { t } = useTranslation('general');
|
||||
|
||||
const onSave = useCallback(async (json: any) => {
|
||||
getVersions();
|
||||
getDocPermissions();
|
||||
if (!isEditing) {
|
||||
setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
|
||||
} else {
|
||||
const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale, t });
|
||||
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(
|
||||
(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 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 (
|
||||
<EditDepthContext.Provider value={1}>
|
||||
@@ -99,10 +100,10 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
CustomComponent={CustomEdit}
|
||||
componentProps={{
|
||||
id,
|
||||
isLoading: !initialState,
|
||||
isLoading: !initialState || !docPermissions,
|
||||
data: dataToRender,
|
||||
collection,
|
||||
permissions: collectionPermissions,
|
||||
permissions: docPermissions,
|
||||
isEditing,
|
||||
onSave,
|
||||
initialState,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import { Permissions } from '../types';
|
||||
import type { PayloadRequest } from '../../express/types';
|
||||
import type { Permissions } from '../types';
|
||||
import type { AllOperations } from '../../types';
|
||||
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 = {
|
||||
req: PayloadRequest
|
||||
@@ -28,75 +29,6 @@ async function accessOperation(args: Arguments): Promise<Permissions> {
|
||||
const isLoggedIn = !!(user);
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
|
||||
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) => {
|
||||
const globalOperations = ['read', 'update'];
|
||||
const globalOperations: AllOperations[] = ['read', 'update'];
|
||||
|
||||
if (global.versions) {
|
||||
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);
|
||||
|
||||
@@ -29,6 +29,7 @@ export type CollectionPermission = {
|
||||
read: Permission
|
||||
update: Permission
|
||||
delete: Permission
|
||||
readVersions?: Permission
|
||||
fields: {
|
||||
[field: string]: FieldPermissions
|
||||
}
|
||||
@@ -37,6 +38,7 @@ export type CollectionPermission = {
|
||||
export type GlobalPermission = {
|
||||
read: Permission
|
||||
update: Permission
|
||||
readVersions?: Permission
|
||||
fields: {
|
||||
[field: string]: FieldPermissions
|
||||
}
|
||||
@@ -44,8 +46,12 @@ export type GlobalPermission = {
|
||||
|
||||
export type Permissions = {
|
||||
canAccessAdmin: boolean
|
||||
collections: CollectionPermission[]
|
||||
globals?: GlobalPermission[]
|
||||
collections: {
|
||||
[collectionSlug: string]: CollectionPermission
|
||||
}
|
||||
globals?: {
|
||||
[globalSlug: string]: GlobalPermission
|
||||
}
|
||||
}
|
||||
|
||||
export type User = {
|
||||
|
||||
@@ -18,6 +18,7 @@ import deleteHandler from './requestHandlers/delete';
|
||||
import findByID from './requestHandlers/findByID';
|
||||
import update, { deprecatedUpdate } from './requestHandlers/update';
|
||||
import logoutHandler from '../auth/requestHandlers/logout';
|
||||
import docAccessRequestHandler from './requestHandlers/docAccess';
|
||||
|
||||
const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => {
|
||||
let { endpoints } = collection;
|
||||
@@ -119,6 +120,11 @@ const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => {
|
||||
method: 'post',
|
||||
handler: create,
|
||||
},
|
||||
{
|
||||
path: '/access/:id',
|
||||
method: 'get',
|
||||
handler: docAccessRequestHandler,
|
||||
},
|
||||
{
|
||||
path: '/:id',
|
||||
method: 'put',
|
||||
|
||||
@@ -35,6 +35,8 @@ import buildWhereInputType from '../../graphql/schema/buildWhereInputType';
|
||||
import getDeleteResolver from './resolvers/delete';
|
||||
import { toWords, formatNames } from '../../utilities/formatLabels';
|
||||
import { Collection, SanitizedCollectionConfig } from '../config/types';
|
||||
import { buildPolicyType } from '../../graphql/schema/buildPoliciesType';
|
||||
import { docAccessResolver } from './resolvers/docAccess';
|
||||
|
||||
function initCollectionsGraphQL(payload: Payload): void {
|
||||
Object.keys(payload.collections).forEach((slug) => {
|
||||
@@ -181,6 +183,19 @@ function initCollectionsGraphQL(payload: Payload): void {
|
||||
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}`] = {
|
||||
type: collection.graphQL.type,
|
||||
args: {
|
||||
|
||||
25
src/collections/graphql/resolvers/docAccess.ts
Normal file
25
src/collections/graphql/resolvers/docAccess.ts
Normal 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;
|
||||
}
|
||||
45
src/collections/operations/docAccess.ts
Normal file
45
src/collections/operations/docAccess.ts
Normal 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;
|
||||
}
|
||||
18
src/collections/requestHandlers/docAccess.ts
Normal file
18
src/collections/requestHandlers/docAccess.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import restoreVersion from './requestHandlers/restoreVersion';
|
||||
import { SanitizedGlobalConfig } from './config/types';
|
||||
import update from './requestHandlers/update';
|
||||
import findOne from './requestHandlers/findOne';
|
||||
import docAccessRequestHandler from './requestHandlers/docAccess';
|
||||
|
||||
const buildEndpoints = (global: SanitizedGlobalConfig): Endpoint[] => {
|
||||
const { endpoints } = global;
|
||||
const { endpoints, slug } = global;
|
||||
|
||||
if (global.versions) {
|
||||
endpoints.push(...[
|
||||
@@ -30,6 +31,11 @@ const buildEndpoints = (global: SanitizedGlobalConfig): Endpoint[] => {
|
||||
}
|
||||
|
||||
endpoints.push(...[
|
||||
{
|
||||
path: '/access',
|
||||
method: 'get',
|
||||
handler: async (req, res, next) => docAccessRequestHandler(req, res, next, global),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
method: 'get',
|
||||
|
||||
@@ -16,11 +16,13 @@ import buildWhereInputType from '../../graphql/schema/buildWhereInputType';
|
||||
import { Field } from '../../fields/config/types';
|
||||
import { toWords } from '../../utilities/formatLabels';
|
||||
import { SanitizedGlobalConfig } from '../config/types';
|
||||
import { buildPolicyType } from '../../graphql/schema/buildPoliciesType';
|
||||
import { docAccessResolver } from './resolvers/docAccess';
|
||||
|
||||
function initGlobalsGraphQL(payload: Payload): void {
|
||||
if (payload.config.globals) {
|
||||
Object.keys(payload.globals.config).forEach((slug) => {
|
||||
const global = payload.globals.config[slug];
|
||||
const global: SanitizedGlobalConfig = payload.globals.config[slug];
|
||||
const {
|
||||
fields,
|
||||
versions,
|
||||
@@ -71,6 +73,16 @@ function initGlobalsGraphQL(payload: Payload): void {
|
||||
resolve: updateResolver(global),
|
||||
};
|
||||
|
||||
payload.Query.fields[`docAccess${formattedName}`] = {
|
||||
type: buildPolicyType({
|
||||
typeSuffix: 'DocAccess',
|
||||
entity: global,
|
||||
type: 'global',
|
||||
scope: 'docAccess',
|
||||
}),
|
||||
resolve: docAccessResolver(global),
|
||||
};
|
||||
|
||||
if (global.versions) {
|
||||
const versionGlobalFields: Field[] = [
|
||||
...buildVersionGlobalFields(global),
|
||||
|
||||
23
src/globals/graphql/resolvers/docAccess.ts
Normal file
23
src/globals/graphql/resolvers/docAccess.ts
Normal 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;
|
||||
}
|
||||
34
src/globals/operations/docAccess.ts
Normal file
34
src/globals/operations/docAccess.ts
Normal 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;
|
||||
}
|
||||
19
src/globals/requestHandlers/docAccess.ts
Normal file
19
src/globals/requestHandlers/docAccess.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,16 @@
|
||||
import { GraphQLJSONObject } from 'graphql-type-json';
|
||||
import { GraphQLBoolean, GraphQLNonNull, GraphQLObjectType } from 'graphql';
|
||||
import formatName from '../utilities/formatName';
|
||||
import { SanitizedCollectionConfig } from '../../collections/config/types';
|
||||
import { SanitizedGlobalConfig } from '../../globals/config/types';
|
||||
import { CollectionConfig, SanitizedCollectionConfig } from '../../collections/config/types';
|
||||
import { GlobalConfig, SanitizedGlobalConfig } from '../../globals/config/types';
|
||||
import { Field } from '../../fields/config/types';
|
||||
import { Payload } from '../..';
|
||||
import { toWords } from '../../utilities/formatLabels';
|
||||
|
||||
type OperationType = 'create' | 'read' | 'update' | 'delete' | 'unlock' | 'readVersions';
|
||||
|
||||
type AccessScopes = 'docAccess' | undefined
|
||||
|
||||
type ObjectTypeFields = {
|
||||
[key in OperationType | 'fields']?: { type: GraphQLObjectType };
|
||||
}
|
||||
@@ -78,24 +80,31 @@ const buildFields = (label, fieldsToBuild) => fieldsToBuild.reduce((builtFields,
|
||||
return builtFields;
|
||||
}, {});
|
||||
|
||||
const buildEntity = (name: string, entityFields: Field[], operations: OperationType[]) => {
|
||||
const formattedName = toWords(name, true);
|
||||
type BuildEntityPolicy = {
|
||||
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 = {
|
||||
fields: {
|
||||
type: new GraphQLObjectType({
|
||||
name: formatName(`${formattedName}Fields`),
|
||||
fields: buildFields(`${formattedName}Fields`, entityFields),
|
||||
name: fieldsTypeName,
|
||||
fields: buildFields(fieldsTypeName, entityFields),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
operations.forEach((operation) => {
|
||||
const capitalizedOperation = operation.charAt(0).toUpperCase() + operation.slice(1);
|
||||
const operationTypeName = toWords(`${name}-${operation}-${scope || 'Access'}`, true);
|
||||
|
||||
fields[operation] = {
|
||||
type: new GraphQLObjectType({
|
||||
name: `${formattedName}${capitalizedOperation}Access`,
|
||||
name: operationTypeName,
|
||||
fields: {
|
||||
permission: { type: new GraphQLNonNull(GraphQLBoolean) },
|
||||
where: { type: GraphQLJSONObject },
|
||||
@@ -107,6 +116,66 @@ const buildEntity = (name: string, entityFields: Field[], operations: OperationT
|
||||
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 {
|
||||
const fields = {
|
||||
canAccessAdmin: {
|
||||
@@ -115,36 +184,26 @@ export default function buildPoliciesType(payload: Payload): GraphQLObjectType {
|
||||
};
|
||||
|
||||
Object.values(payload.config.collections).forEach((collection: SanitizedCollectionConfig) => {
|
||||
const collectionOperations: OperationType[] = ['create', 'read', 'update', 'delete'];
|
||||
|
||||
if (collection.auth && (typeof collection.auth.maxLoginAttempts !== 'undefined' && collection.auth.maxLoginAttempts !== 0)) {
|
||||
collectionOperations.push('unlock');
|
||||
}
|
||||
|
||||
if (collection.versions) {
|
||||
collectionOperations.push('readVersions');
|
||||
}
|
||||
const collectionPolicyType = buildPolicyType({
|
||||
typeSuffix: 'Access',
|
||||
entity: collection,
|
||||
type: 'collection',
|
||||
});
|
||||
|
||||
fields[formatName(collection.slug)] = {
|
||||
type: new GraphQLObjectType({
|
||||
name: formatName(`${collection.slug}Access`),
|
||||
fields: buildEntity(collection.slug, collection.fields, collectionOperations),
|
||||
}),
|
||||
type: collectionPolicyType,
|
||||
};
|
||||
});
|
||||
|
||||
Object.values(payload.config.globals).forEach((global: SanitizedGlobalConfig) => {
|
||||
const globalOperations: OperationType[] = ['read', 'update'];
|
||||
|
||||
if (global.versions) {
|
||||
globalOperations.push('readVersions');
|
||||
}
|
||||
const globalPolicyType = buildPolicyType({
|
||||
typeSuffix: 'Access',
|
||||
entity: global,
|
||||
type: 'global',
|
||||
});
|
||||
|
||||
fields[formatName(global.slug)] = {
|
||||
type: new GraphQLObjectType({
|
||||
name: formatName(`${global?.graphQL?.name || global.slug}Access`),
|
||||
fields: buildEntity(global?.graphQL?.name || global.slug, global.fields, globalOperations),
|
||||
}),
|
||||
type: globalPolicyType,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface PayloadMongooseDocument extends MongooseDocument {
|
||||
}
|
||||
|
||||
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 {
|
||||
return doc?.createdAt && doc?.updatedAt;
|
||||
|
||||
196
src/utilities/getEntityPolicies.ts
Normal file
196
src/utilities/getEntityPolicies.ts
Normal 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>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const restrictedSlug = 'restricted';
|
||||
export const restrictedVersionsSlug = 'restricted-versions';
|
||||
export const siblingDataSlug = 'sibling-data';
|
||||
export const relyOnRequestHeadersSlug = 'rely-on-request-headers';
|
||||
export const docLevelAccessSlug = 'doc-level-access';
|
||||
|
||||
const openAccess = {
|
||||
create: () => true,
|
||||
@@ -32,7 +33,7 @@ const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => {
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: 'users'
|
||||
user: 'users',
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
@@ -45,7 +46,7 @@ export default buildConfig({
|
||||
setTimeout(resolve, 50, true); // set to 'true' or 'false' here to simulate the response
|
||||
}),
|
||||
},
|
||||
fields: []
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
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) => {
|
||||
await payload.create({
|
||||
|
||||
@@ -4,7 +4,7 @@ import payload from '../../src';
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
|
||||
import { initPayloadE2E } from '../helpers/configHelpers';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -17,16 +17,17 @@ import type { ReadOnlyCollection, RestrictedVersion } from './payload-types';
|
||||
*/
|
||||
|
||||
const { beforeAll, describe } = test;
|
||||
|
||||
describe('access control', () => {
|
||||
let page: Page;
|
||||
let url: AdminUrlUtil;
|
||||
let restrictedUrl: AdminUrlUtil;
|
||||
let readOnlyUrl: AdminUrlUtil;
|
||||
let restrictedVersionsUrl: AdminUrlUtil;
|
||||
let serverURL: string;
|
||||
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadE2E(__dirname);
|
||||
const config = await initPayloadE2E(__dirname);
|
||||
serverURL = config.serverURL;
|
||||
|
||||
url = new AdminUrlUtil(serverURL, slug);
|
||||
restrictedUrl = new AdminUrlUtil(serverURL, restrictedSlug);
|
||||
@@ -174,11 +175,51 @@ describe('access control', () => {
|
||||
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 }> {
|
||||
return payload.create({
|
||||
collection: slug,
|
||||
collection: docLevelAccessSlug,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user