Skip to content

Commit eda6f70

Browse files
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
1 parent d9c45f6 commit eda6f70

File tree

23 files changed

+702
-154
lines changed

23 files changed

+702
-154
lines changed

docs/admin/hooks.mdx

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ const {
5454

5555
### useFormFields
5656

57-
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.
57+
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.
5858

5959
<Banner type="success">
60-
<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.
60+
<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.
6161
</Banner>
6262

6363
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 = () => {
8484

8585
### useAllFormFields
8686

87-
**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>]]`.
87+
**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>]]`.
8888

8989
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.
9090

@@ -100,7 +100,7 @@ const ExampleComponent: React.FC = () => {
100100
// The result below will reflect the data stored in the form at the given time
101101
const formData = reduceFieldsToValues(fields, true);
102102

103-
// Pass in field state and a path,
103+
// Pass in field state and a path,
104104
// and you will be sent all sibling data of the path that you've specified
105105
const siblingData = getSiblingData(fields, 'someFieldName');
106106

@@ -135,7 +135,7 @@ The `useForm` hook can be used to interact with the form itself, and sends back
135135

136136
<Banner type="warning">
137137
<strong>Warning:</strong><br/>
138-
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.
138+
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.
139139
</Banner>
140140

141141
The `useForm` hook returns an object with the following properties:
@@ -162,17 +162,19 @@ The `useForm` hook returns an object with the following properties:
162162

163163
The `useDocumentInfo` hook provides lots of information about the document currently being edited, including the following:
164164

165-
| Property | Description |
166-
|---------------------------|------------------------------------------------------------------------------------|
167-
| **`collection`** | If the doc is a collection, its collection config will be returned |
168-
| **`global`** | If the doc is a global, its global config will be returned |
169-
| **`type`** | The type of document being edited (collection or global) |
170-
| **`id`** | If the doc is a collection, its ID will be returned |
171-
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences |
172-
| **`versions`** | Versions of the current doc |
173-
| **`unpublishedVersions`** | Unpublished versions of the current doc |
174-
| **`publishedDoc`** | The currently published version of the doc being edited |
175-
| **`getVersions`** | Method to trigger the retrieval of document versions |
165+
| Property | Description |
166+
|---------------------------|--------------------------------------------------------------------------------------------------------------------| |
167+
| **`collection`** | If the doc is a collection, its collection config will be returned |
168+
| **`global`** | If the doc is a global, its global config will be returned |
169+
| **`type`** | The type of document being edited (collection or global) |
170+
| **`id`** | If the doc is a collection, its ID will be returned |
171+
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences |
172+
| **`versions`** | Versions of the current doc |
173+
| **`unpublishedVersions`** | Unpublished versions of the current doc |
174+
| **`publishedDoc`** | The currently published version of the doc being edited |
175+
| **`getVersions`** | Method to trigger the retrieval of document versions |
176+
| **`docPermissions`** | The current documents permissions. Collection document permissions fallback when no id is present (i.e. on create) |
177+
| **`getDocPermissions`** | Method to trigger the retrieval of document level permissions |
176178

177179
**Example:**
178180

@@ -250,7 +252,7 @@ const Greeting: React.FC = () => {
250252

251253
### useConfig
252254

253-
Used to easily fetch the full Payload config.
255+
Used to easily fetch the full Payload config.
254256

255257
```tsx
256258
import { useConfig } from 'payload/components/utilities';

docs/authentication/operations.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ query {
6666
}
6767
```
6868

69+
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`.
70+
6971
### Me
7072

7173
Returns either a logged in user with token or null when there is no logged in user.

src/admin/components/utilities/DocumentInfo/index.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import qs from 'qs';
55
import { useTranslation } from 'react-i18next';
66
import { useConfig } from '../Config';
77
import { PaginatedDocs } from '../../../../mongoose/types';
8-
import { ContextType, Props, Version } from './types';
8+
import { ContextType, DocumentPermissions, EntityType, Props, Version } from './types';
99
import { TypeWithID } from '../../../../globals/config/types';
1010
import { TypeWithTimestamps } from '../../../../collections/config/types';
1111
import { Where } from '../../../../types';
1212
import { DocumentPreferences } from '../../../../preferences/types';
1313
import { usePreferences } from '../Preferences';
14+
import { useAuth } from '../Auth';
1415

1516
const Context = createContext({} as ContextType);
1617

@@ -23,24 +24,29 @@ export const DocumentInfoProvider: React.FC<Props> = ({
2324
const { serverURL, routes: { api } } = useConfig();
2425
const { getPreference } = usePreferences();
2526
const { i18n } = useTranslation();
27+
const { permissions } = useAuth();
2628
const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null);
2729
const [versions, setVersions] = useState<PaginatedDocs<Version>>(null);
2830
const [unpublishedVersions, setUnpublishedVersions] = useState<PaginatedDocs<Version>>(null);
31+
const [docPermissions, setDocPermissions] = useState<DocumentPermissions>(null);
2932

3033
const baseURL = `${serverURL}${api}`;
31-
let slug;
32-
let type;
33-
let preferencesKey;
34+
let slug: string;
35+
let type: EntityType;
36+
let pluralType: 'globals' | 'collections';
37+
let preferencesKey: string;
3438

3539
if (global) {
3640
slug = global.slug;
3741
type = 'global';
42+
pluralType = 'globals';
3843
preferencesKey = `global-${slug}`;
3944
}
4045

4146
if (collection) {
4247
slug = collection.slug;
4348
type = 'collection';
49+
pluralType = 'collections';
4450

4551
if (id) {
4652
preferencesKey = `collection-${slug}-${id}`;
@@ -169,6 +175,25 @@ export const DocumentInfoProvider: React.FC<Props> = ({
169175
}
170176
}, [i18n, global, collection, id, baseURL]);
171177

178+
const getDocPermissions = React.useCallback(async () => {
179+
let docAccessURL: string;
180+
if (pluralType === 'globals') {
181+
docAccessURL = `/globals/${slug}/access`;
182+
} else if (pluralType === 'collections' && id) {
183+
docAccessURL = `/${slug}/access/${id}`;
184+
}
185+
186+
if (docAccessURL) {
187+
const res = await fetch(`${serverURL}${api}${docAccessURL}`);
188+
const json = await res.json();
189+
setDocPermissions(json);
190+
} else {
191+
// fallback to permissions from the collection
192+
// (i.e. create has no id)
193+
setDocPermissions(permissions[pluralType][slug]);
194+
}
195+
}, [serverURL, api, pluralType, slug, id, permissions]);
196+
172197
useEffect(() => {
173198
getVersions();
174199
}, [getVersions]);
@@ -181,6 +206,10 @@ export const DocumentInfoProvider: React.FC<Props> = ({
181206
getDocPreferences();
182207
}, [getPreference, preferencesKey]);
183208

209+
useEffect(() => {
210+
getDocPermissions();
211+
}, [getDocPermissions]);
212+
184213
const value = {
185214
slug,
186215
type,
@@ -192,6 +221,8 @@ export const DocumentInfoProvider: React.FC<Props> = ({
192221
getVersions,
193222
publishedDoc,
194223
id,
224+
getDocPermissions,
225+
docPermissions,
195226
};
196227

197228
return (
@@ -202,5 +233,3 @@ export const DocumentInfoProvider: React.FC<Props> = ({
202233
};
203234

204235
export const useDocumentInfo = (): ContextType => useContext(Context);
205-
206-
export default Context;

src/admin/components/utilities/DocumentInfo/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import React from 'react';
2+
import { CollectionPermission, GlobalPermission } from '../../../../auth';
23
import { SanitizedCollectionConfig, TypeWithID, TypeWithTimestamps } from '../../../../collections/config/types';
34
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
45
import { PaginatedDocs } from '../../../../mongoose/types';
56
import { TypeWithVersion } from '../../../../versions/types';
67

78
export type Version = TypeWithVersion<any>
89

10+
export type DocumentPermissions = null | GlobalPermission | CollectionPermission
11+
12+
export type EntityType = 'global' | 'collection'
13+
914
export type ContextType = {
1015
collection?: SanitizedCollectionConfig
1116
global?: SanitizedGlobalConfig
12-
type: 'global' | 'collection'
17+
type: EntityType
1318
/** Slug of the collection or global */
1419
slug?: string
1520
id?: string | number
@@ -18,6 +23,8 @@ export type ContextType = {
1823
unpublishedVersions?: PaginatedDocs<Version>
1924
publishedDoc?: TypeWithID & TypeWithTimestamps & { _status?: string }
2025
getVersions: () => Promise<void>
26+
docPermissions: DocumentPermissions
27+
getDocPermissions: () => Promise<void>
2128
}
2229

2330
export type Props = {

src/admin/components/views/Global/index.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ const GlobalView: React.FC<IndexProps> = (props) => {
2020
const { state: locationState } = useLocation<{data?: Record<string, unknown>}>();
2121
const locale = useLocale();
2222
const { setStepNav } = useStepNav();
23-
const { permissions, user } = useAuth();
23+
const { user } = useAuth();
2424
const [initialState, setInitialState] = useState<Fields>();
25-
const { getVersions, preferencesKey } = useDocumentInfo();
25+
const { getVersions, preferencesKey, docPermissions, getDocPermissions } = useDocumentInfo();
2626
const { getPreference } = usePreferences();
2727
const { t } = useTranslation();
2828

@@ -50,9 +50,10 @@ const GlobalView: React.FC<IndexProps> = (props) => {
5050

5151
const onSave = useCallback(async (json) => {
5252
getVersions();
53+
getDocPermissions();
5354
const state = await buildStateFromSchema({ fieldSchema: fields, data: json.result, operation: 'update', user, locale, t });
5455
setInitialState(state);
55-
}, [getVersions, fields, user, locale, t]);
56+
}, [getVersions, fields, user, locale, t, getDocPermissions]);
5657

5758
const [{ data }] = usePayloadAPI(
5859
`${serverURL}${api}/globals/${slug}`,
@@ -79,16 +80,14 @@ const GlobalView: React.FC<IndexProps> = (props) => {
7980
awaitInitialState();
8081
}, [dataToRender, fields, user, locale, getPreference, preferencesKey, t]);
8182

82-
const globalPermissions = permissions?.globals?.[slug];
83-
8483
return (
8584
<RenderCustomComponent
8685
DefaultComponent={DefaultGlobal}
8786
CustomComponent={CustomEdit}
8887
componentProps={{
89-
isLoading: !initialState,
88+
isLoading: !initialState || !docPermissions,
9089
data: dataToRender,
91-
permissions: globalPermissions,
90+
permissions: docPermissions,
9291
initialState,
9392
global,
9493
onSave,

src/admin/components/views/collections/Edit/index.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useDocumentInfo } from '../../../utilities/DocumentInfo';
1515
import { Fields } from '../../../forms/Form/types';
1616
import { usePreferences } from '../../../utilities/Preferences';
1717
import { EditDepthContext } from '../../../utilities/EditDepth';
18+
import { CollectionPermission } from '../../../../../auth';
1819

1920
const EditView: React.FC<IndexProps> = (props) => {
2021
const { collection: incomingCollection, isEditing } = props;
@@ -40,20 +41,21 @@ const EditView: React.FC<IndexProps> = (props) => {
4041
const { state: locationState } = useLocation();
4142
const history = useHistory();
4243
const [initialState, setInitialState] = useState<Fields>();
43-
const { permissions, user } = useAuth();
44-
const { getVersions, preferencesKey } = useDocumentInfo();
44+
const { user } = useAuth();
45+
const { getVersions, preferencesKey, getDocPermissions, docPermissions } = useDocumentInfo();
4546
const { getPreference } = usePreferences();
4647
const { t } = useTranslation('general');
4748

4849
const onSave = useCallback(async (json: any) => {
4950
getVersions();
51+
getDocPermissions();
5052
if (!isEditing) {
5153
setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
5254
} else {
5355
const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale, t });
5456
setInitialState(state);
5557
}
56-
}, [admin, collection, isEditing, getVersions, user, id, t, locale]);
58+
}, [admin, collection, isEditing, getVersions, user, id, t, locale, getDocPermissions]);
5759

5860
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
5961
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null),
@@ -87,10 +89,9 @@ const EditView: React.FC<IndexProps> = (props) => {
8789
);
8890
}
8991

90-
const collectionPermissions = permissions?.collections?.[slug];
9192
const apiURL = `${serverURL}${api}/${slug}/${id}${collection.versions.drafts ? '?draft=true' : ''}`;
9293
const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`;
93-
const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission);
94+
const hasSavePermission = (isEditing && docPermissions?.update?.permission) || (!isEditing && (docPermissions as CollectionPermission)?.create?.permission);
9495

9596
return (
9697
<EditDepthContext.Provider value={1}>
@@ -99,10 +100,10 @@ const EditView: React.FC<IndexProps> = (props) => {
99100
CustomComponent={CustomEdit}
100101
componentProps={{
101102
id,
102-
isLoading: !initialState,
103+
isLoading: !initialState || !docPermissions,
103104
data: dataToRender,
104105
collection,
105-
permissions: collectionPermissions,
106+
permissions: docPermissions,
106107
isEditing,
107108
onSave,
108109
initialState,

0 commit comments

Comments
 (0)