@abinnovision/payloadcms-mcpx 2.0.0-beta.13 → 2.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +50 -35
  2. package/dist/admin/capability-matrix.d.mts +4 -3
  3. package/dist/admin/capability-matrix.mjs +262 -111
  4. package/dist/admin/capability-toggles.mjs +84 -40
  5. package/dist/admin/confirmations-panel.mjs +242 -0
  6. package/dist/admin/confirmations.d.mts +15 -0
  7. package/dist/admin/confirmations.mjs +70 -0
  8. package/dist/admin/index.d.mts +2 -1
  9. package/dist/admin/index.mjs +2 -1
  10. package/dist/api-keys/capability-matrix.d.mts +5 -4
  11. package/dist/api-keys/capability-matrix.mjs +33 -12
  12. package/dist/api-keys/collection.mjs +3 -3
  13. package/dist/api-keys/confirmation-view.mjs +6 -0
  14. package/dist/api-keys/fields.mjs +20 -3
  15. package/dist/builtin-tool-names.mjs +3 -1
  16. package/dist/capabilities.mjs +9 -4
  17. package/dist/define-tool.d.mts +4 -4
  18. package/dist/define-tool.mjs +8 -1
  19. package/dist/endpoint/confirmations.mjs +124 -0
  20. package/dist/endpoint/handler.mjs +2 -2
  21. package/dist/endpoint/server.mjs +22 -18
  22. package/dist/endpoint/upload.mjs +5 -4
  23. package/dist/grants/confirmation.mjs +106 -0
  24. package/dist/{upload → grants}/grant.mjs +39 -16
  25. package/dist/options.mjs +32 -6
  26. package/dist/plugin.d.mts +2 -1
  27. package/dist/plugin.mjs +5 -2
  28. package/dist/tools/builtin.mjs +5 -1
  29. package/dist/tools/create-document.mjs +1 -1
  30. package/dist/tools/delete-document.mjs +157 -0
  31. package/dist/tools/find-versions.mjs +1 -1
  32. package/dist/tools/get-document.mjs +1 -1
  33. package/dist/tools/list-capabilities.mjs +2 -2
  34. package/dist/tools/patch-document.mjs +2 -2
  35. package/dist/tools/publish-document.mjs +1 -1
  36. package/dist/tools/run-confirmed.mjs +126 -0
  37. package/dist/tools/shared.mjs +4 -0
  38. package/dist/tools/validate-document.mjs +2 -2
  39. package/dist/types.d.mts +130 -19
  40. package/dist/upload/file.mjs +3 -3
  41. package/dist/write/draft-guard.mjs +11 -9
  42. package/dist/write/publish-intent.mjs +16 -6
  43. package/package.json +1 -1
@@ -1,40 +1,59 @@
1
- import { CAPABILITY_OPERATIONS, cellPath, toolPath } from "../api-keys/capability-matrix.mjs";
1
+ import { STORED_OPERATIONS, cellPath, toolPath } from "../api-keys/capability-matrix.mjs";
2
2
  //#region src/admin/capability-toggles.ts
3
- const stateOf = (paths, values) => {
4
- if (paths.length === 0) return "off";
5
- const granted = paths.filter((path) => values[path] === true).length;
3
+ /**
4
+ * How far a key may go with an entity. Each level includes the ones before it.
5
+ */ const ACCESS_LEVELS = [
6
+ "none",
7
+ "read",
8
+ "write",
9
+ "publish"
10
+ ];
11
+ const toolsState = (matrix, basePath, values) => {
12
+ const granted = matrix.tools.filter((tool) => values[toolPath(basePath, tool.name)] === true).length;
6
13
  if (granted === 0) return "off";
7
- return granted === paths.length ? "on" : "mixed";
14
+ return granted === matrix.tools.length ? "on" : "mixed";
8
15
  };
9
16
  /**
10
- * Whether a column's bulk toggle reads as on, off or indeterminate. Cells the
11
- * config does not expose are ignored: two granted cells and one dash read as on.
12
- */ const columnState = (matrix, basePath, namespace, operation, values) => stateOf(matrix[namespace].filter((row) => row[operation]).map((row) => cellPath(basePath, namespace, row.fieldName, operation)), values);
17
+ * The levels a row offers: none, then each operation the config exposes.
18
+ */ const accessLevelsOf = (row) => ACCESS_LEVELS.filter((level) => level === "none" || row[level]);
19
+ /**
20
+ * The highest level whose flags are all granted, counting only operations the
21
+ * config exposes. A write without a read reads as none, as on the server.
22
+ */ const accessLevelOf = (basePath, namespace, row, values) => {
23
+ let reached = "none";
24
+ for (const level of ACCESS_LEVELS) {
25
+ if (level === "none") continue;
26
+ if (!row[level] || values[cellPath(basePath, namespace, row.fieldName, level)] !== true) break;
27
+ reached = level;
28
+ }
29
+ return reached;
30
+ };
13
31
  /**
14
- * Whether an entity's row toggle reads as on, off or indeterminate, over the
15
- * operations the config exposes for that row.
16
- */ const rowState = (basePath, namespace, row, values) => stateOf(CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)), values);
17
- const toolsState = (matrix, basePath, values) => stateOf(matrix.tools.map((tool) => toolPath(basePath, tool.name)), values);
32
+ * The delete mode a row's stored flags read as.
33
+ */ const deleteModeOf = (basePath, namespace, row, values) => {
34
+ const granted = (operation) => values[cellPath(basePath, namespace, row.fieldName, operation)] === true;
35
+ if (!granted("read") || !granted("delete")) return "off";
36
+ return row.deleteUnattended && granted("deleteUnattended") ? "trash" : "approve";
37
+ };
38
+ const requiresOf = (operation) => {
39
+ const found = STORED_OPERATIONS.find((candidate) => candidate.id === operation);
40
+ return found && "requires" in found ? found.requires : void 0;
41
+ };
42
+ const prerequisitesOf = (operation) => {
43
+ const required = requiresOf(operation);
44
+ return required ? [required, ...prerequisitesOf(required)] : [];
45
+ };
18
46
  const reconcile = (next, touched) => {
19
- if (touched.has("publish") && next.publish) return {
20
- ...next,
21
- write: true
22
- };
23
- if (touched.has("write") && !next.write) return {
24
- ...next,
25
- publish: false
26
- };
27
- return next;
47
+ const result = { ...next };
48
+ for (const operation of touched) if (result[operation]) for (const required of prerequisitesOf(operation)) result[required] = true;
49
+ else for (const { id } of STORED_OPERATIONS) if (prerequisitesOf(id).includes(operation)) result[id] = false;
50
+ return result;
28
51
  };
29
52
  const rowActions = (basePath, namespace, row, values, changes) => {
30
- const current = {
31
- read: values[cellPath(basePath, namespace, row.fieldName, "read")] === true,
32
- write: values[cellPath(basePath, namespace, row.fieldName, "write")] === true,
33
- publish: values[cellPath(basePath, namespace, row.fieldName, "publish")] === true
34
- };
53
+ const current = Object.fromEntries(STORED_OPERATIONS.map((operation) => [operation.id, values[cellPath(basePath, namespace, row.fieldName, operation.id)] === true]));
35
54
  const touched = /* @__PURE__ */ new Set();
36
55
  const next = { ...current };
37
- for (const operation of CAPABILITY_OPERATIONS) {
56
+ for (const operation of STORED_OPERATIONS) {
38
57
  const change = changes[operation.id];
39
58
  if (change !== void 0 && row[operation.id]) {
40
59
  next[operation.id] = change;
@@ -42,32 +61,57 @@ const rowActions = (basePath, namespace, row, values, changes) => {
42
61
  }
43
62
  }
44
63
  const reconciled = reconcile(next, touched);
45
- return CAPABILITY_OPERATIONS.filter((operation) => row[operation.id] && reconciled[operation.id] !== current[operation.id]).map((operation) => ({
64
+ return STORED_OPERATIONS.filter((operation) => row[operation.id] && reconciled[operation.id] !== current[operation.id]).map((operation) => ({
46
65
  type: "UPDATE",
47
66
  path: cellPath(basePath, namespace, row.fieldName, operation.id),
48
67
  value: reconciled[operation.id]
49
68
  }));
50
69
  };
70
+ const grantsOf = (level) => {
71
+ const rank = ACCESS_LEVELS.indexOf(level);
72
+ return {
73
+ read: rank >= 1,
74
+ write: rank >= 2,
75
+ publish: rank >= 3
76
+ };
77
+ };
51
78
  /**
52
- * The form-state updates one click implies, as literal actions. Every toggle
53
- * (cell, row, column or tool) comes through here, so the publish rule applies
54
- * to bulk grants too.
79
+ * The form-state updates one click implies, as literal actions. Every click
80
+ * comes through here, so the dependency rules apply to the "All" controls too:
81
+ * picking Write ticks read, and lowering access to none clears delete.
55
82
  *
56
- * Only cells whose value changes get an action, so a bulk toggle that changes
57
- * nothing does not mark the form modified.
83
+ * Only cells whose value changes get an action, so a click that changes
84
+ * nothing does not mark the form modified. A level above a row's ceiling
85
+ * grants what the row exposes and no more.
58
86
  */ const buildToggleActions = (matrix, basePath, values, intent) => {
59
87
  if (intent.kind === "tool" || intent.kind === "tools") return matrix.tools.filter((tool) => intent.kind === "tools" || tool.name === intent.name).filter((tool) => values[toolPath(basePath, tool.name)] === true !== intent.value).map((tool) => ({
60
88
  type: "UPDATE",
61
89
  path: toolPath(basePath, tool.name),
62
90
  value: intent.value
63
91
  }));
64
- const rows = matrix[intent.namespace].filter((row) => intent.kind === "column" || row.fieldName === intent.fieldName);
65
- const changes = intent.kind === "row" ? {
66
- read: intent.value,
67
- write: intent.value,
68
- publish: intent.value
69
- } : { [intent.operation]: intent.value };
92
+ const rows = matrix[intent.namespace].filter((row) => intent.fieldName === void 0 || row.fieldName === intent.fieldName);
93
+ const changes = intent.kind === "access" ? grantsOf(intent.level) : {
94
+ delete: intent.mode !== "off",
95
+ deleteUnattended: intent.mode === "trash"
96
+ };
70
97
  return rows.flatMap((row) => rowActions(basePath, intent.namespace, row, values, changes));
71
98
  };
99
+ /**
100
+ * The level a namespace's "All" control shows: the one that would change
101
+ * nothing if picked, so rows capped below it still count. `undefined` while
102
+ * the rows differ.
103
+ */ const allAccessLevel = (matrix, basePath, namespace, values) => ACCESS_LEVELS.find((level) => buildToggleActions(matrix, basePath, values, {
104
+ kind: "access",
105
+ namespace,
106
+ level
107
+ }).length === 0);
108
+ /**
109
+ * The delete mode the "All" control shows, by the same rule. It offers no
110
+ * trash, so a row that trashes directly reads as mixed.
111
+ */ const allDeleteMode = (matrix, basePath, namespace, values) => ["off", "approve"].find((mode) => buildToggleActions(matrix, basePath, values, {
112
+ kind: "deleteMode",
113
+ namespace,
114
+ mode
115
+ }).length === 0);
72
116
  //#endregion
73
- export { buildToggleActions, columnState, rowState, toolsState };
117
+ export { accessLevelOf, accessLevelsOf, allAccessLevel, allDeleteMode, buildToggleActions, deleteModeOf, toolsState };
@@ -0,0 +1,242 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Button } from "@payloadcms/ui";
4
+ //#region src/admin/confirmations-panel.tsx
5
+ /**
6
+ * The element id of a call's row, which the page scrolls to.
7
+ */ const confirmationRowId = (handle) => `mcpx-confirmation-${handle}`;
8
+ const CELL_PADDING = "calc(var(--base) * 0.4) calc(var(--base) * 0.6)";
9
+ const styles = {
10
+ panel: {
11
+ marginBottom: "var(--base)",
12
+ padding: "calc(var(--base) * 0.75)",
13
+ border: "1px solid var(--theme-elevation-150)",
14
+ borderRadius: "3px",
15
+ background: "var(--theme-elevation-50)"
16
+ },
17
+ heading: { margin: 0 },
18
+ lead: {
19
+ margin: "calc(var(--base) * 0.25) 0 calc(var(--base) * 0.75)",
20
+ color: "var(--theme-elevation-500)"
21
+ },
22
+ group: { marginBottom: "calc(var(--base) * 0.75)" },
23
+ groupHeading: { margin: "0 0 calc(var(--base) * 0.25)" },
24
+ table: {
25
+ width: "100%",
26
+ borderCollapse: "collapse",
27
+ textAlign: "left"
28
+ },
29
+ head: {
30
+ padding: CELL_PADDING,
31
+ color: "var(--theme-elevation-400)",
32
+ fontWeight: "normal",
33
+ borderBottom: "1px solid var(--theme-border-color)"
34
+ },
35
+ cell: {
36
+ padding: CELL_PADDING,
37
+ verticalAlign: "middle",
38
+ borderBottom: "1px solid var(--theme-elevation-100)"
39
+ },
40
+ highlighted: { background: "var(--theme-elevation-100)" },
41
+ muted: { color: "var(--theme-elevation-500)" },
42
+ status: {
43
+ display: "block",
44
+ color: "var(--theme-elevation-500)",
45
+ fontSize: "0.85em"
46
+ },
47
+ permanent: {
48
+ color: "var(--theme-error-500)",
49
+ fontWeight: 600
50
+ },
51
+ actions: {
52
+ display: "flex",
53
+ gap: "calc(var(--base) * 0.25)",
54
+ justifyContent: "flex-end"
55
+ },
56
+ footer: {
57
+ display: "flex",
58
+ gap: "calc(var(--base) * 0.5)"
59
+ }
60
+ };
61
+ const groupsOf = (confirmations) => {
62
+ const groups = /* @__PURE__ */ new Map();
63
+ for (const confirmation of confirmations) groups.set(confirmation.group, [...groups.get(confirmation.group) ?? [], confirmation]);
64
+ return [...groups];
65
+ };
66
+ const approveAllLabel = (confirmations) => {
67
+ const permanent = confirmations.filter((confirmation) => confirmation.summary.permanent).length;
68
+ return permanent === 0 ? `Approve all (${String(confirmations.length)})` : `Approve all (${String(confirmations.length)}, ${String(permanent)} permanent)`;
69
+ };
70
+ const Row = ({ busy, confirmation, onDecide }) => {
71
+ const { handle, highlighted, summary } = confirmation;
72
+ const cell = highlighted ? {
73
+ ...styles.cell,
74
+ ...styles.highlighted
75
+ } : styles.cell;
76
+ const name = summary.title ?? summary.id ?? "Unreadable";
77
+ return /*#__PURE__*/ jsxs("tr", {
78
+ "aria-current": highlighted ? "true" : void 0,
79
+ id: confirmationRowId(handle),
80
+ children: [
81
+ /*#__PURE__*/ jsx("td", {
82
+ style: cell,
83
+ children: summary.label
84
+ }),
85
+ /*#__PURE__*/ jsxs("td", {
86
+ style: cell,
87
+ children: [
88
+ summary.href ? /*#__PURE__*/ jsx("a", {
89
+ href: summary.href,
90
+ rel: "noreferrer",
91
+ target: "_blank",
92
+ children: name
93
+ }) : name,
94
+ summary.title && summary.id ? /*#__PURE__*/ jsxs("span", {
95
+ style: styles.muted,
96
+ children: [
97
+ " (",
98
+ summary.id,
99
+ ")"
100
+ ]
101
+ }) : null,
102
+ summary.status ? /*#__PURE__*/ jsx("span", {
103
+ style: styles.status,
104
+ children: summary.status
105
+ }) : null
106
+ ]
107
+ }),
108
+ /*#__PURE__*/ jsx("td", {
109
+ style: cell,
110
+ children: summary.permanent ? /*#__PURE__*/ jsx("span", {
111
+ style: styles.permanent,
112
+ children: "Permanent"
113
+ }) : "Moves to trash"
114
+ }),
115
+ /*#__PURE__*/ jsx("td", {
116
+ style: cell,
117
+ children: summary.reason ? /*#__PURE__*/ jsxs("span", { children: [/*#__PURE__*/ jsx("span", {
118
+ style: styles.muted,
119
+ children: "The client wrote: "
120
+ }), summary.reason] }) : /*#__PURE__*/ jsx("span", {
121
+ style: styles.muted,
122
+ children: "No reason given"
123
+ })
124
+ }),
125
+ /*#__PURE__*/ jsx("td", {
126
+ style: cell,
127
+ children: /*#__PURE__*/ jsxs("div", {
128
+ style: styles.actions,
129
+ children: [/*#__PURE__*/ jsx(Button, {
130
+ buttonStyle: "primary",
131
+ disabled: busy,
132
+ margin: false,
133
+ onClick: () => {
134
+ onDecide([handle], "approved");
135
+ },
136
+ size: "small",
137
+ type: "button",
138
+ children: "Approve"
139
+ }), /*#__PURE__*/ jsx(Button, {
140
+ buttonStyle: "secondary",
141
+ disabled: busy,
142
+ margin: false,
143
+ onClick: () => {
144
+ onDecide([handle], "rejected");
145
+ },
146
+ size: "small",
147
+ type: "button",
148
+ children: "Reject"
149
+ })]
150
+ })
151
+ })
152
+ ]
153
+ });
154
+ };
155
+ /**
156
+ * The calls of an API key waiting for its user's decision, grouped by tool.
157
+ * Renders nothing when none is waiting.
158
+ */ const ConfirmationsPanel = ({ busy, confirmations, onDecide }) => {
159
+ if (confirmations.length === 0) return null;
160
+ const handles = confirmations.map((confirmation) => confirmation.handle);
161
+ return /*#__PURE__*/ jsxs("section", {
162
+ className: "field-type",
163
+ style: styles.panel,
164
+ children: [
165
+ /*#__PURE__*/ jsx("h3", {
166
+ style: styles.heading,
167
+ children: "Waiting for your approval"
168
+ }),
169
+ /*#__PURE__*/ jsx("p", {
170
+ style: styles.lead,
171
+ children: "A client using this key asked for these calls. Approving runs nothing: the client runs approved calls with runConfirmed. Each call expires 15 minutes after it was requested."
172
+ }),
173
+ groupsOf(confirmations).map(([group, entries]) => /*#__PURE__*/ jsxs("div", {
174
+ style: styles.group,
175
+ children: [/*#__PURE__*/ jsx("h4", {
176
+ style: styles.groupHeading,
177
+ children: group
178
+ }), /*#__PURE__*/ jsxs("table", {
179
+ style: styles.table,
180
+ children: [/*#__PURE__*/ jsx("thead", { children: /*#__PURE__*/ jsxs("tr", { children: [
181
+ /*#__PURE__*/ jsx("th", {
182
+ scope: "col",
183
+ style: styles.head,
184
+ children: "Collection"
185
+ }),
186
+ /*#__PURE__*/ jsx("th", {
187
+ scope: "col",
188
+ style: styles.head,
189
+ children: "Document"
190
+ }),
191
+ /*#__PURE__*/ jsx("th", {
192
+ scope: "col",
193
+ style: styles.head,
194
+ children: "Effect"
195
+ }),
196
+ /*#__PURE__*/ jsx("th", {
197
+ scope: "col",
198
+ style: styles.head,
199
+ children: "Reason"
200
+ }),
201
+ /*#__PURE__*/ jsx("th", {
202
+ scope: "col",
203
+ style: styles.head,
204
+ children: /*#__PURE__*/ jsx("span", {
205
+ className: "sr-only",
206
+ children: "Decision"
207
+ })
208
+ })
209
+ ] }) }), /*#__PURE__*/ jsx("tbody", { children: entries.map((confirmation) => /*#__PURE__*/ jsx(Row, {
210
+ busy,
211
+ confirmation,
212
+ onDecide
213
+ }, confirmation.handle)) })]
214
+ })]
215
+ }, group)),
216
+ /*#__PURE__*/ jsxs("div", {
217
+ style: styles.footer,
218
+ children: [/*#__PURE__*/ jsx(Button, {
219
+ buttonStyle: "primary",
220
+ disabled: busy,
221
+ margin: false,
222
+ onClick: () => {
223
+ onDecide(handles, "approved");
224
+ },
225
+ type: "button",
226
+ children: approveAllLabel(confirmations)
227
+ }), /*#__PURE__*/ jsx(Button, {
228
+ buttonStyle: "secondary",
229
+ disabled: busy,
230
+ margin: false,
231
+ onClick: () => {
232
+ onDecide(handles, "rejected");
233
+ },
234
+ type: "button",
235
+ children: "Reject all"
236
+ })]
237
+ })
238
+ ]
239
+ });
240
+ };
241
+ //#endregion
242
+ export { ConfirmationsPanel, confirmationRowId };
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ //#region src/admin/confirmations.d.ts
3
+ interface McpxConfirmationsProps {
4
+ /**
5
+ * Endpoint path below the API route, from the plugin options.
6
+ */
7
+ endpointPath: string;
8
+ }
9
+ /**
10
+ * The calls waiting for approval on the API key edit view. Reads and decides
11
+ * through the plugin's endpoints, never through the key's form, so a decision
12
+ * does not mark the key modified. Renders nothing while none is waiting.
13
+ */
14
+ export declare const McpxConfirmations: React.FC<McpxConfirmationsProps>;
15
+ //#endregion
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ import { CONFIRMATIONS_PATH } from "../api-keys/confirmation-view.mjs";
3
+ import { ConfirmationsPanel, confirmationRowId } from "./confirmations-panel.mjs";
4
+ import { jsx } from "react/jsx-runtime";
5
+ import { toast, useConfig, useDocumentInfo } from "@payloadcms/ui";
6
+ import { useCallback, useEffect, useState } from "react";
7
+ //#region src/admin/confirmations.tsx
8
+ /**
9
+ * The calls waiting for approval on the API key edit view. Reads and decides
10
+ * through the plugin's endpoints, never through the key's form, so a decision
11
+ * does not mark the key modified. Renders nothing while none is waiting.
12
+ */ const McpxConfirmations = ({ endpointPath }) => {
13
+ const { id } = useDocumentInfo();
14
+ const { config } = useConfig();
15
+ const [confirmations, setConfirmations] = useState([]);
16
+ const [busy, setBusy] = useState(false);
17
+ const [version, setVersion] = useState(0);
18
+ const url = `${config.serverURL}${config.routes.api}${endpointPath}${CONFIRMATIONS_PATH}`;
19
+ useEffect(() => {
20
+ if (id === void 0) return;
21
+ let active = true;
22
+ const params = new URLSearchParams({ key: String(id) });
23
+ const highlight = new URLSearchParams(window.location.search).get("confirmation");
24
+ if (highlight !== null) params.set("confirmation", highlight);
25
+ fetch(`${url}?${params.toString()}`, { credentials: "include" }).then(async (response) => response.ok ? await response.json() : { confirmations: [] }).then((body) => {
26
+ if (active) setConfirmations(body.confirmations);
27
+ }).catch(() => void 0);
28
+ return () => {
29
+ active = false;
30
+ };
31
+ }, [
32
+ id,
33
+ url,
34
+ version
35
+ ]);
36
+ const highlighted = confirmations.find((entry) => entry.highlighted)?.handle;
37
+ useEffect(() => {
38
+ if (highlighted !== void 0) document.getElementById(confirmationRowId(highlighted))?.scrollIntoView({ block: "center" });
39
+ }, [highlighted]);
40
+ const decide = useCallback((handles, decision) => {
41
+ setBusy(true);
42
+ fetch(url, {
43
+ method: "POST",
44
+ credentials: "include",
45
+ headers: { "content-type": "application/json" },
46
+ body: JSON.stringify({
47
+ key: id,
48
+ handles,
49
+ decision
50
+ })
51
+ }).then(async (response) => {
52
+ if (!response.ok) {
53
+ const body = await response.json().catch(() => ({}));
54
+ toast.error(body.error ?? "The decision could not be saved.");
55
+ }
56
+ }).catch(() => {
57
+ toast.error("The decision could not be saved.");
58
+ }).finally(() => {
59
+ setBusy(false);
60
+ setVersion((current) => current + 1);
61
+ });
62
+ }, [id, url]);
63
+ return /*#__PURE__*/ jsx(ConfirmationsPanel, {
64
+ busy,
65
+ confirmations,
66
+ onDecide: decide
67
+ });
68
+ };
69
+ //#endregion
70
+ export { McpxConfirmations };
@@ -1,3 +1,4 @@
1
1
  import { McpxCapabilityMatrix } from "./capability-matrix.mjs";
2
+ import { McpxConfirmations } from "./confirmations.mjs";
2
3
  import { McpxSetupGuide } from "./setup-guide.mjs";
3
- export { McpxCapabilityMatrix, McpxSetupGuide };
4
+ export { McpxCapabilityMatrix, McpxConfirmations, McpxSetupGuide };
@@ -1,3 +1,4 @@
1
1
  import { McpxCapabilityMatrix } from "./capability-matrix.mjs";
2
+ import { McpxConfirmations } from "./confirmations.mjs";
2
3
  import { McpxSetupGuide } from "./setup-guide.mjs";
3
- export { McpxCapabilityMatrix, McpxSetupGuide };
4
+ export { McpxCapabilityMatrix, McpxConfirmations, McpxSetupGuide };
@@ -2,8 +2,7 @@ import "../options.mjs";
2
2
  //#region src/api-keys/capability-matrix.d.ts
3
3
  /**
4
4
  * One entity's row. The booleans say what the plugin config exposes, not what
5
- * the key was granted. A `false` renders as a dash, so absence reads as a
6
- * refusal by config rather than a gap.
5
+ * the key was granted. A `false` leaves its segment out of the row's controls.
7
6
  */
8
7
  export interface CapabilityRow {
9
8
  fieldName: string;
@@ -14,10 +13,12 @@ export interface CapabilityRow {
14
13
  read: boolean;
15
14
  write: boolean;
16
15
  publish: boolean;
16
+ delete: boolean;
17
+ deleteUnattended: boolean;
17
18
  /**
18
- * Said only where a row departs from what its column header promises.
19
+ * Writes go live immediately, as the entity keeps no drafts.
19
20
  */
20
- hint?: string;
21
+ live?: boolean;
21
22
  }
22
23
  interface CapabilityTool {
23
24
  name: string;
@@ -2,12 +2,12 @@ import { CAPABILITIES_FIELD, canPublish, canWrite, isLiveWrite } from "../capabi
2
2
  //#region src/api-keys/capability-matrix.ts
3
3
  /**
4
4
  * The group's `admin.description`, rendered by the matrix in Payload's
5
- * description slot. It explains both markings in the table: an unticked box
6
- * and a dash.
7
- */ const CAPABILITIES_DESCRIPTION = "What this key may do. An unticked box is a refusal, and a dash means the plugin config does not expose that operation at all.";
5
+ * description slot. It explains how to read the segmented controls.
6
+ */ const CAPABILITIES_DESCRIPTION = "What this key may do. Each level includes the ones before it, and a missing segment means the plugin config does not expose it.";
8
7
  /**
9
8
  * The operations a collection or global can expose. The generated checkboxes
10
- * and the matrix column headers share this wording.
9
+ * and the matrix segments share this wording. `requires` names the
10
+ * operation a grant depends on, which the server and the matrix both enforce.
11
11
  */ const CAPABILITY_OPERATIONS = [
12
12
  {
13
13
  id: "read",
@@ -17,27 +17,48 @@ import { CAPABILITIES_FIELD, canPublish, canWrite, isLiveWrite } from "../capabi
17
17
  {
18
18
  id: "write",
19
19
  label: "Write",
20
- description: "Create, patch and validate drafts."
20
+ description: "Create, patch and validate drafts.",
21
+ requires: "read"
21
22
  },
22
23
  {
23
24
  id: "publish",
24
25
  label: "Publish",
25
- description: "Promote the current draft to what the public sees."
26
+ description: "Promote the current draft to what the public sees.",
27
+ requires: "write"
28
+ },
29
+ {
30
+ id: "delete",
31
+ label: "Delete",
32
+ description: "Delete documents. Each one is approved by this key's user in the admin panel, unless the config allows trashing directly and this key is set to.",
33
+ requires: "read"
26
34
  }
27
35
  ];
28
- const LIVE_HINT = "Writes go live immediately.";
29
- const hintFor = (entity) => isLiveWrite(entity) ? { hint: LIVE_HINT } : {};
36
+ /**
37
+ * Stored as a checkbox of its own but drawn as the Trash segment of the Delete
38
+ * control.
39
+ */ const DELETE_UNATTENDED = {
40
+ id: "deleteUnattended",
41
+ label: "Trash directly",
42
+ description: "Move documents to trash without asking this key's user.",
43
+ requires: "delete"
44
+ };
45
+ /**
46
+ * Every operation with a stored checkbox: the capability operations plus
47
+ * the unattended delete.
48
+ */ const STORED_OPERATIONS = [...CAPABILITY_OPERATIONS, DELETE_UNATTENDED];
30
49
  const toRow = (entity) => ({
31
50
  fieldName: entity.fieldName,
32
51
  label: entity.slug,
33
52
  read: entity.read,
34
53
  write: canWrite(entity),
35
54
  publish: canPublish(entity),
36
- ...hintFor(entity)
55
+ delete: entity.delete,
56
+ deleteUnattended: entity.deleteUnattended,
57
+ ...isLiveWrite(entity) ? { live: true } : {}
37
58
  });
38
59
  /**
39
60
  * The single description of what the config exposes. `createCapabilityFields`
40
- * and the admin component both draw from it, so a cell cannot appear without a
61
+ * and the admin component both draw from it, so a control cannot appear without a
41
62
  * field behind it.
42
63
  */ const createCapabilityMatrix = (options) => ({
43
64
  collections: options.collections.map(toRow),
@@ -51,6 +72,6 @@ const cellPath = (basePath, namespace, fieldName, operation) => `${basePath}.${n
51
72
  const toolPath = (basePath, name) => `${basePath}.tools.${name}`;
52
73
  /**
53
74
  * Every path the matrix can address, in a stable order.
54
- */ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) => CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
75
+ */ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) => STORED_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
55
76
  //#endregion
56
- export { CAPABILITIES_DESCRIPTION, CAPABILITY_OPERATIONS, capabilityPaths, cellPath, createCapabilityMatrix, toolPath };
77
+ export { CAPABILITIES_DESCRIPTION, CAPABILITY_OPERATIONS, STORED_OPERATIONS, capabilityPaths, cellPath, createCapabilityMatrix, toolPath };
@@ -1,4 +1,4 @@
1
- import { createCapabilityFields, createKeyFields, withSetupGuideTab } from "./fields.mjs";
1
+ import { createCapabilityFields, createConfirmationFields, createKeyFields, withSetupGuideTab } from "./fields.mjs";
2
2
  import { generateApiKey, hashApiKey } from "./key.mjs";
3
3
  //#region src/api-keys/collection.ts
4
4
  /**
@@ -37,7 +37,7 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
37
37
  delete: ownKeysOnly
38
38
  },
39
39
  hooks: { beforeChange: [keyBeforeChange] },
40
- fields: withSetupGuideTab([
40
+ fields: [...createConfirmationFields(options), ...withSetupGuideTab([
41
41
  {
42
42
  name: "user",
43
43
  type: "relationship",
@@ -52,7 +52,7 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
52
52
  },
53
53
  ...createKeyFields(),
54
54
  ...createCapabilityFields(options)
55
- ], options)
55
+ ], options)]
56
56
  };
57
57
  };
58
58
  //#endregion
@@ -0,0 +1,6 @@
1
+ //#region src/api-keys/confirmation-view.ts
2
+ /**
3
+ * Path of the confirmations endpoints below the plugin's endpoint path.
4
+ */ const CONFIRMATIONS_PATH = "/confirmations";
5
+ //#endregion
6
+ export { CONFIRMATIONS_PATH };