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:
@@ -163,7 +163,7 @@ 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) |
|
||||
@@ -173,6 +173,8 @@ The `useDocumentInfo` hook provides lots of information about the document curre
|
||||
| **`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:**
|
||||
|
||||
|
||||
@@ -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