@bison-lab/payload-core 3.14.0 → 3.16.0

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.
package/dist/index.mjs CHANGED
@@ -1,6 +1,5 @@
1
- import { $ as defaultFeaturesFieldValue, A as ROLES_SLUG, B as CAPABILITIES, C as DUPLICATE_ROLE_NAME_MESSAGE, D as MISSING_SEED_ROLES_MESSAGE, E as LAST_USERS_TICK_MESSAGE, F as resetRolesMatrix, G as THEME_FEATURE_SLUGS, H as DEFAULT_ROLE_GRANTS, I as roleDescription, J as isRoleSlug, K as isRole, L as roleSelectOptions, M as defaultRolesFieldValue, N as normalizeStoredRoles, O as ROLES_FIELD_DESCRIPTION, P as parseRolesMatrix, Q as MISSING_PACKAGE_FEATURES_MESSAGE, R as seedRoleRows, S as DEVELOPER_DESCRIPTION, T as INVALID_ROLE_NAME_MESSAGE, U as ROLES, V as CAPABILITY_FEATURES, W as ROLE_LABELS, X as sanitizeRoleNameInput, Y as roleLabel, Z as slugifyRoleName, _ as isDeveloper, a as hideUnlessFeature, at as FEATURES_SLUG, b as storedRoles, c as canManageContent, ct as PACKAGE_FEATURES, d as hasCapability, dt as isFeatureSlug, et as featureCatalogue, f as hasGrant, ft as isLockedFeature, g as isAuthenticated, h as isAdminOrSelf, i as hasFeature, it as validateFeaturesMatrix, j as applyRoleSlugsFromNames, k as ROLES_GLOBAL_DESCRIPTION, l as canPublish, lt as PACKAGE_FEATURE_SLUGS, m as isAdmin, n as canUseFeature, nt as parseFeaturesMatrix, o as authenticatedOrPublished, ot as FEATURE_GROUPS, p as hasRole, pt as isPackageFeatureSlug, q as isRoleName, r as getFeaturesMatrix, rt as stampFeatureCatalogue, s as canManageBrand, st as FEATURE_GROUP_LABELS, tt as featureGroupLabel, u as getRolesMatrix, ut as isFeatureGroupId, v as isDeveloperTab, w as DUPLICATE_ROLE_SLUG_MESSAGE, x as DEFAULT_ROLE_MATRIX, y as isPrivilegedRole, z as validateRolesMatrix } from "./access-B3LglN2J.mjs";
2
- import { n as documentTitle, r as titleTemplate, t as SHARE_IMAGE_SIZE } from "./share-image-BlFAJLnd.mjs";
3
- import { A as THEME_FONT_FIELD, C as validateThemeHex, D as THEME_COLOR_SCALE_FIELD, E as THEME_COLOR_FIELD, F as THEME_PUBLISH_FIELD, I as THEME_SAVE_BUTTON, L as THEME_SECTION_HEADING, M as THEME_IDENTITY_FALLBACK, N as THEME_LIBRARY_FIELD, O as THEME_CONTRAST_REPORT, P as THEME_PAIRING_FIELD, R as headingSelectValue, S as validateSourceIncluded, T as THEME_APPEARANCE_FIELD, _ as THEME_SLUG, a as pageEditorLooks, c as findColorTokens, d as rewriteColorToken, f as themeColorKeys, g as THEME_IDENTITY_SLUG, h as THEME_COLORS_SLUG, i as PAGE_EDITOR_SYSTEM_KEYS, j as THEME_GREY_SCALE_FIELD, k as THEME_DOCUMENT_CONTROLS, l as rewriteColorTokens, m as THEME_APPEARANCE_SLUG, n as colorTokenField, o as pageEditorTokens, p as SYSTEM_COLOR_KEYS, r as lookField, s as themeLibraryFromDoc, t as resolveThemeIdentity, u as deleteLibraryColor, v as THEME_TYPOGRAPHY_SLUG, w as LOOK_FIELD, x as themeConfigFromDoc, y as docFromConfig } from "./identity-Dwyq8519.mjs";
1
+ import { n as documentTitle, r as titleTemplate, t as SHARE_IMAGE_SIZE } from "./share-image-C4ILz4p2.mjs";
2
+ import { A as THEME_FONT_FIELD, C as validateThemeHex, D as THEME_COLOR_SCALE_FIELD, E as THEME_COLOR_FIELD, F as THEME_PUBLISH_FIELD, I as THEME_SAVE_BUTTON, L as THEME_SECTION_HEADING, M as THEME_IDENTITY_FALLBACK, N as THEME_LIBRARY_FIELD, O as THEME_CONTRAST_REPORT, P as THEME_PAIRING_FIELD, R as headingSelectValue, S as validateSourceIncluded, T as THEME_APPEARANCE_FIELD, _ as THEME_SLUG, a as pageEditorLooks, c as findColorTokens, d as rewriteColorToken, f as themeColorKeys, g as THEME_IDENTITY_SLUG, h as THEME_COLORS_SLUG, i as PAGE_EDITOR_SYSTEM_KEYS, j as THEME_GREY_SCALE_FIELD, k as THEME_DOCUMENT_CONTROLS, l as rewriteColorTokens, m as THEME_APPEARANCE_SLUG, n as colorTokenField, o as pageEditorTokens, p as SYSTEM_COLOR_KEYS, r as lookField, s as themeLibraryFromDoc, t as resolveThemeIdentity, u as deleteLibraryColor, v as THEME_TYPOGRAPHY_SLUG, w as LOOK_FIELD, x as themeConfigFromDoc, y as docFromConfig } from "./identity-LAzSeorC.mjs";
4
3
  import { seoPlugin as seoPlugin$1 } from "@payloadcms/plugin-seo";
5
4
  import { catalog } from "@bison-lab/fonts";
6
5
  import { DESTRUCTIVE_SCALE_HEX, SHADE_STEPS, presetHints } from "@bison-lab/tokens";
@@ -169,24 +168,20 @@ const APPEARANCE_CHOICES = {
169
168
  /** Theme-store REST path. LibraryField calls `/api/globals/theme` + this. */
170
169
  const COLOR_USAGES_PATH = "/color-usages";
171
170
  /**
172
- * Walk every current document in the named collections and globals
171
+ * Walk every current document in the resolved collections and globals
173
172
  * (drafts included). A color only on an unpublished page is still in use.
174
173
  */
175
174
  async function findColorUsages(payload, scopes, key) {
175
+ const { collections, globals } = resolveScopes(payload, scopes);
176
176
  const usages = [];
177
- for (const collection of scopes?.collections ?? []) for await (const doc of eachCollectionDoc(payload, collection)) for (const hit of findColorTokens(doc, key)) usages.push({
177
+ for (const collection of collections) for await (const doc of eachCollectionDoc(payload, collection)) for (const hit of findColorTokens(doc, key)) usages.push({
178
178
  collection,
179
179
  id: idOf(doc),
180
180
  path: hit.path,
181
181
  token: hit.token
182
182
  });
183
- for (const slug of scopes?.globals ?? []) {
184
- const doc = await payload.findGlobal({
185
- slug,
186
- draft: true,
187
- depth: 0,
188
- overrideAccess: true
189
- });
183
+ for (const slug of globals) {
184
+ const doc = await readGlobal(payload, slug);
190
185
  if (!doc) continue;
191
186
  for (const hit of findColorTokens(doc, key)) usages.push({
192
187
  global: slug,
@@ -197,7 +192,8 @@ async function findColorUsages(payload, scopes, key) {
197
192
  return usages;
198
193
  }
199
194
  async function rewriteColorUsages(payload, scopes, fromKey, toKey) {
200
- for (const collection of scopes?.collections ?? []) for await (const doc of eachCollectionDoc(payload, collection)) {
195
+ const { collections, globals } = resolveScopes(payload, scopes);
196
+ for (const collection of collections) for await (const doc of eachCollectionDoc(payload, collection)) {
201
197
  if (findColorTokens(doc, fromKey).length === 0) continue;
202
198
  const { id: _id, ...data } = rewriteColorTokens(doc, fromKey, toKey);
203
199
  const id = idOf(doc);
@@ -210,13 +206,8 @@ async function rewriteColorUsages(payload, scopes, fromKey, toKey) {
210
206
  overrideAccess: true
211
207
  });
212
208
  }
213
- for (const slug of scopes?.globals ?? []) {
214
- const doc = await payload.findGlobal({
215
- slug,
216
- draft: true,
217
- depth: 0,
218
- overrideAccess: true
219
- });
209
+ for (const slug of globals) {
210
+ const doc = await readGlobal(payload, slug);
220
211
  if (!doc || findColorTokens(doc, fromKey).length === 0) continue;
221
212
  await payload.updateGlobal({
222
213
  slug,
@@ -233,17 +224,52 @@ async function replacementKeys(payload, except) {
233
224
  overrideAccess: true
234
225
  })).map((look) => look.value).filter((key) => key !== except);
235
226
  }
236
- async function* eachCollectionDoc(payload, collection) {
237
- let page = 1;
238
- for (;;) {
239
- const result = await payload.find({
240
- collection,
227
+ function resolveScopes(payload, scopes) {
228
+ if (scopes === true) return registeredScopes(payload);
229
+ return {
230
+ collections: scopes?.collections ?? [],
231
+ globals: scopes?.globals ?? []
232
+ };
233
+ }
234
+ function registeredScopes(payload) {
235
+ return {
236
+ collections: slugsOf(payload.config?.collections ?? payload.collections).filter((slug) => !slug.startsWith("payload-")),
237
+ globals: slugsOf(payload.config?.globals ?? payload.globals?.config).filter((slug) => slug !== THEME_SLUG)
238
+ };
239
+ }
240
+ function slugsOf(value) {
241
+ if (Array.isArray(value)) return value.map((item) => item?.slug).filter((slug) => Boolean(slug));
242
+ if (value && typeof value === "object") return Object.keys(value);
243
+ return [];
244
+ }
245
+ async function readGlobal(payload, slug) {
246
+ try {
247
+ return await payload.findGlobal({
248
+ slug,
241
249
  draft: true,
242
250
  depth: 0,
243
- limit: 100,
244
- page,
245
251
  overrideAccess: true
246
252
  });
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+ async function* eachCollectionDoc(payload, collection) {
258
+ let page = 1;
259
+ for (;;) {
260
+ let result;
261
+ try {
262
+ result = await payload.find({
263
+ collection,
264
+ draft: true,
265
+ depth: 0,
266
+ limit: 100,
267
+ page,
268
+ overrideAccess: true
269
+ });
270
+ } catch {
271
+ return;
272
+ }
247
273
  for (const doc of result.docs) yield doc;
248
274
  const totalPages = result.totalPages ?? (result.docs.length < 100 ? page : page + 1);
249
275
  if (page >= totalPages || result.docs.length === 0) break;
@@ -304,75 +330,664 @@ async function readJson(req) {
304
330
  return null;
305
331
  }
306
332
  //#endregion
307
- //#region src/theme/publish.ts
333
+ //#region src/features/types.ts
334
+ const FEATURES_SLUG = "features";
335
+ const FEATURE_GROUPS = [
336
+ "pages",
337
+ "media",
338
+ "theme",
339
+ "users"
340
+ ];
341
+ const FEATURE_GROUP_LABELS = {
342
+ pages: "Pages",
343
+ media: "Media",
344
+ theme: "Theme",
345
+ users: "Users"
346
+ };
347
+ const PACKAGE_FEATURE_SLUGS = [
348
+ "content",
349
+ "publish",
350
+ "media",
351
+ "theme-colors",
352
+ "theme-typography",
353
+ "theme-appearance",
354
+ "theme-identity",
355
+ "brand-assets",
356
+ "users",
357
+ "roles"
358
+ ];
308
359
  /**
309
- * Publishing a child writes that slice only. Other responsibilities stay
310
- * as they already are on the stored document. Unsaved form state on a
311
- * different child never goes live. A first save (no stored row) still
312
- * writes only the named child; `getPublishedTheme` fills the rest from
313
- * the seed.
360
+ * Package catalogue. Features itself is not a row that screen is
361
+ * Developer-only in code. Empty Global falls back to these defaults.
314
362
  */
315
- function publishThemeChild(stored, incoming, child) {
316
- if (child === "colors") return {
317
- ...stored,
318
- colors: incoming.colors
363
+ const PACKAGE_FEATURES = [
364
+ {
365
+ slug: "content",
366
+ label: "Content",
367
+ group: "pages",
368
+ defaultReleased: true
369
+ },
370
+ {
371
+ slug: "publish",
372
+ label: "Publish",
373
+ group: "pages",
374
+ defaultReleased: true
375
+ },
376
+ {
377
+ slug: "media",
378
+ label: "Media",
379
+ group: "media",
380
+ defaultReleased: true
381
+ },
382
+ {
383
+ slug: "theme-colors",
384
+ label: "Colors",
385
+ group: "theme",
386
+ defaultReleased: true
387
+ },
388
+ {
389
+ slug: "theme-typography",
390
+ label: "Typography",
391
+ group: "theme",
392
+ defaultReleased: true
393
+ },
394
+ {
395
+ slug: "theme-appearance",
396
+ label: "Appearance",
397
+ group: "theme",
398
+ defaultReleased: true
399
+ },
400
+ {
401
+ slug: "theme-identity",
402
+ label: "Identity",
403
+ group: "theme",
404
+ defaultReleased: true
405
+ },
406
+ {
407
+ slug: "brand-assets",
408
+ label: "Brand assets",
409
+ group: "theme",
410
+ defaultReleased: true
411
+ },
412
+ {
413
+ slug: "users",
414
+ label: "Users",
415
+ group: "users",
416
+ defaultReleased: true
417
+ },
418
+ {
419
+ slug: "roles",
420
+ label: "Roles",
421
+ group: "users",
422
+ defaultReleased: true
423
+ }
424
+ ];
425
+ function isFeatureGroupId(value) {
426
+ return typeof value === "string" && FEATURE_GROUPS.includes(value);
427
+ }
428
+ function isPackageFeatureSlug(value) {
429
+ return typeof value === "string" && PACKAGE_FEATURE_SLUGS.includes(value);
430
+ }
431
+ function isFeatureSlug(value) {
432
+ return typeof value === "string" && /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(value);
433
+ }
434
+ /** @deprecated Locks are gone; the release switch is the valve. */
435
+ function isLockedFeature(_slug, _locked) {
436
+ return false;
437
+ }
438
+ //#endregion
439
+ //#region src/features/matrix.ts
440
+ const MISSING_PACKAGE_FEATURES_MESSAGE = "Features must include Content, Publish, Media, Theme screens, Brand assets, Users, and Roles.";
441
+ function featureCatalogue(extras = []) {
442
+ return [...PACKAGE_FEATURES.map((feature) => ({ ...feature })), ...extras.map((extra) => ({
443
+ slug: extra.slug,
444
+ label: extra.label,
445
+ group: extra.group ?? "users",
446
+ defaultReleased: extra.defaultReleased ?? false
447
+ }))];
448
+ }
449
+ function defaultFeaturesFieldValue(extras = []) {
450
+ return featureCatalogue(extras).map((feature) => ({
451
+ id: feature.slug,
452
+ slug: feature.slug,
453
+ label: feature.label,
454
+ group: feature.group,
455
+ released: feature.defaultReleased
456
+ }));
457
+ }
458
+ function stampFeatureCatalogue(value, extras = []) {
459
+ const bySlug = new Map(featureCatalogue(extras).map((feature) => [feature.slug, feature]));
460
+ if (!Array.isArray(value)) return defaultFeaturesFieldValue(extras);
461
+ return value.flatMap((item) => {
462
+ if (!item || typeof item !== "object") return [];
463
+ const slug = "slug" in item && typeof item.slug === "string" ? item.slug : "";
464
+ const feature = bySlug.get(slug);
465
+ if (!feature) return [];
466
+ return [{
467
+ id: feature.slug,
468
+ slug: feature.slug,
469
+ label: feature.label,
470
+ group: feature.group,
471
+ released: Boolean("released" in item && item.released)
472
+ }];
473
+ });
474
+ }
475
+ function parseFeaturesMatrix(value) {
476
+ if (!Array.isArray(value) || value.length === 0) return {
477
+ ok: false,
478
+ message: MISSING_PACKAGE_FEATURES_MESSAGE
319
479
  };
320
- if (child === "typography") return {
321
- ...stored,
322
- typography: incoming.typography
480
+ const rows = [];
481
+ const seen = /* @__PURE__ */ new Set();
482
+ for (const item of value) {
483
+ if (!item || typeof item !== "object") continue;
484
+ const slug = "slug" in item ? item.slug : void 0;
485
+ if (!isFeatureSlug(slug) || seen.has(slug)) continue;
486
+ seen.add(slug);
487
+ const pack = PACKAGE_FEATURES.find((feature) => feature.slug === slug);
488
+ const label = "label" in item && typeof item.label === "string" ? item.label : pack?.label ?? slug;
489
+ const group = "group" in item && isFeatureGroupId(item.group) ? item.group : pack?.group ?? "users";
490
+ rows.push({
491
+ id: slug,
492
+ slug,
493
+ label,
494
+ group,
495
+ released: Boolean("released" in item && item.released)
496
+ });
497
+ }
498
+ if (PACKAGE_FEATURES.some((feature) => !seen.has(feature.slug))) return {
499
+ ok: false,
500
+ message: MISSING_PACKAGE_FEATURES_MESSAGE
323
501
  };
324
- if (child === "appearance") return {
325
- ...stored,
326
- appearance: incoming.appearance
502
+ return {
503
+ ok: true,
504
+ rows
327
505
  };
506
+ }
507
+ function validateFeaturesMatrix(value, extras = []) {
508
+ const parsed = parseFeaturesMatrix(value);
509
+ if (!parsed.ok) return parsed.message;
510
+ const allowed = new Set(featureCatalogue(extras).map((feature) => feature.slug));
511
+ if (parsed.rows.some((row) => !allowed.has(row.slug))) return MISSING_PACKAGE_FEATURES_MESSAGE;
512
+ return true;
513
+ }
514
+ function featureGroupLabel(group) {
515
+ return isFeatureGroupId(group) ? FEATURE_GROUP_LABELS[group] : group;
516
+ }
517
+ function packageFeatureLabel(slug, extras = []) {
518
+ const pack = PACKAGE_FEATURES.find((feature) => feature.slug === slug);
519
+ if (pack) return pack.label;
520
+ return extras.find((feature) => feature.slug === slug)?.label ?? slug;
521
+ }
522
+ function isGroupOnlySlug(slug) {
523
+ return isFeatureGroupId(slug) && !PACKAGE_FEATURES.some((feature) => feature.slug === slug);
524
+ }
525
+ //#endregion
526
+ //#region src/roles/types.ts
527
+ /**
528
+ * The Roles Global a site's generated types will describe. Optional and
529
+ * nullable, no index signature: a generated Global is assignable to this,
530
+ * never the reverse.
531
+ */
532
+ const ROLES = [
533
+ "developer",
534
+ "admin",
535
+ "designer",
536
+ "author"
537
+ ];
538
+ const ROLE_LABELS = {
539
+ developer: "Developer",
540
+ admin: "Admin",
541
+ designer: "Designer",
542
+ author: "Author"
543
+ };
544
+ /** Capability aliases over feature grants. Brand is any Theme-group leaf. */
545
+ const CAPABILITIES = [
546
+ "content",
547
+ "brand",
548
+ "publish",
549
+ "users"
550
+ ];
551
+ const THEME_FEATURE_SLUGS = [
552
+ "theme-colors",
553
+ "theme-typography",
554
+ "theme-appearance",
555
+ "theme-identity",
556
+ "brand-assets"
557
+ ];
558
+ const CAPABILITY_FEATURES = {
559
+ content: ["content"],
560
+ publish: ["publish"],
561
+ users: ["users"],
562
+ brand: THEME_FEATURE_SLUGS
563
+ };
564
+ const DEFAULT_ROLE_GRANTS = {
565
+ developer: [],
566
+ admin: [
567
+ "content",
568
+ "publish",
569
+ "media",
570
+ "users",
571
+ "roles"
572
+ ],
573
+ designer: [
574
+ "content",
575
+ "media",
576
+ ...THEME_FEATURE_SLUGS,
577
+ "users",
578
+ "roles"
579
+ ],
580
+ author: ["content", "media"]
581
+ };
582
+ function isRole(value) {
583
+ return typeof value === "string" && ROLES.includes(value);
584
+ }
585
+ /** Lowercase slug from a letters-and-spaces name: `designer`, `the-designer`. */
586
+ function isRoleSlug(value) {
587
+ return typeof value === "string" && /^[a-z]+(-[a-z]+)*$/.test(value);
588
+ }
589
+ /** Display name: letters and single spaces only. `The Greatest Designer`. */
590
+ function isRoleName(value) {
591
+ return typeof value === "string" && /^[A-Za-z]+(?: [A-Za-z]+)*$/.test(value.trim());
592
+ }
593
+ /** Strip digits and punctuation as the name is typed. Trailing space stays. */
594
+ function sanitizeRoleNameInput(value) {
595
+ return value.replace(/[^A-Za-z ]/g, "").replace(/ {2,}/g, " ");
596
+ }
597
+ /** Name → stored key. `The Greatest Designer` → `the-greatest-designer`. */
598
+ function slugifyRoleName(value) {
599
+ if (typeof value !== "string") return "";
600
+ return value.trim().toLowerCase().replace(/[^a-z]+/g, "-").replace(/^-|-$/g, "");
601
+ }
602
+ function roleLabel(role, label) {
603
+ const trimmed = typeof label === "string" ? label.trim() : "";
604
+ if (trimmed) return trimmed;
605
+ return isRole(role) ? ROLE_LABELS[role] : role;
606
+ }
607
+ function defaultGrantsForRole(role, extraGrants) {
608
+ if (isRole(role)) return [...DEFAULT_ROLE_GRANTS[role]];
609
+ return extraGrants ? [...extraGrants] : [];
610
+ }
611
+ //#endregion
612
+ //#region src/roles/matrix.ts
613
+ const ROLES_SLUG = "roles";
614
+ const ROLES_GLOBAL_DESCRIPTION = "Who may do what. Drag to change rank.";
615
+ const ROLES_FIELD_DESCRIPTION = "Drag to change rank. Ticks are features released on Features. Developer has the whole catalogue.";
616
+ /**
617
+ * Default rank and grants. Developer is implicit (empty grants). Brand
618
+ * screens default to Designer.
619
+ */
620
+ const DEFAULT_ROLE_MATRIX = ROLES.map((role) => ({
621
+ role,
622
+ grants: defaultGrantsForRole(role)
623
+ }));
624
+ const DEVELOPER_DESCRIPTION = "Everything, including features not released for assignment.";
625
+ const LAST_USERS_TICK_MESSAGE = "Keep Users granted on at least one of Admin or Developer.";
626
+ const MISSING_SEED_ROLES_MESSAGE = "Roles must include Developer, Admin, Designer, and Author.";
627
+ const DUPLICATE_ROLE_SLUG_MESSAGE = "Each role needs a unique slug.";
628
+ const DUPLICATE_ROLE_NAME_MESSAGE = "Each role needs a unique name.";
629
+ const INVALID_ROLE_NAME_MESSAGE = "Use letters and spaces only.";
630
+ function uniqueSlugs(values) {
631
+ const slugs = [];
632
+ for (const value of values) if (typeof value === "string" && isFeatureSlug(value) && !slugs.includes(value)) slugs.push(value);
633
+ return slugs;
634
+ }
635
+ /** Read stored grants, or rebuild them from the old capability ticks. */
636
+ function grantsFromStoredRole(item) {
637
+ if ("grants" in item && Array.isArray(item.grants)) return uniqueSlugs(item.grants);
638
+ const grants = [];
639
+ if ("content" in item && item.content) grants.push("content", "media");
640
+ if ("publish" in item && item.publish) grants.push("publish");
641
+ if ("users" in item && item.users) grants.push("users", "roles");
642
+ if ("brand" in item && item.brand) grants.push(...THEME_FEATURE_SLUGS);
643
+ return uniqueSlugs(grants);
644
+ }
645
+ function seedRoleRows(extras = []) {
646
+ return [...DEFAULT_ROLE_MATRIX.map((row) => ({
647
+ ...row,
648
+ grants: [...row.grants],
649
+ label: isRole(row.role) ? ROLE_LABELS[row.role] : row.role
650
+ })), ...extras.map((extra) => ({
651
+ role: extra.role,
652
+ label: extra.label,
653
+ grants: extra.grants ? [...extra.grants] : []
654
+ }))];
655
+ }
656
+ function defaultRolesFieldValue(extras = []) {
657
+ return seedRoleRows(extras).map((row) => ({
658
+ id: row.role,
659
+ ...row
660
+ }));
661
+ }
662
+ function readRoleRow(item) {
663
+ const role = "role" in item ? item.role : void 0;
664
+ if (typeof role !== "string" || role.trim() === "") return { error: DUPLICATE_ROLE_SLUG_MESSAGE };
665
+ if (!isRoleSlug(role)) return { error: DUPLICATE_ROLE_SLUG_MESSAGE };
328
666
  return {
329
- ...stored,
330
- logo: incoming.logo,
331
- favicon: incoming.favicon,
332
- logoMark: incoming.logoMark
667
+ id: role,
668
+ role,
669
+ label: roleLabel(role, "label" in item && typeof item.label === "string" ? item.label : void 0),
670
+ grants: grantsFromStoredRole(item)
333
671
  };
334
672
  }
335
- function sliceFromTheme(theme, child) {
336
- if (child === "colors") return { colors: theme.colors };
337
- if (child === "typography") return { typography: theme.typography };
338
- if (child === "appearance") return { appearance: theme.appearance };
673
+ function parseRolesMatrix(value) {
674
+ if (!Array.isArray(value)) return {
675
+ ok: false,
676
+ message: MISSING_SEED_ROLES_MESSAGE
677
+ };
678
+ const rows = [];
679
+ const seen = /* @__PURE__ */ new Set();
680
+ for (const item of value) {
681
+ if (!item || typeof item !== "object") continue;
682
+ const role = "role" in item ? item.role : void 0;
683
+ if (typeof role !== "string" || role.trim() === "") continue;
684
+ const parsed = readRoleRow(item);
685
+ if ("error" in parsed) return {
686
+ ok: false,
687
+ message: parsed.error
688
+ };
689
+ if (seen.has(parsed.role)) return {
690
+ ok: false,
691
+ message: DUPLICATE_ROLE_SLUG_MESSAGE
692
+ };
693
+ seen.add(parsed.role);
694
+ rows.push(parsed);
695
+ }
696
+ if (ROLES.some((role) => !seen.has(role))) return {
697
+ ok: false,
698
+ message: MISSING_SEED_ROLES_MESSAGE
699
+ };
339
700
  return {
340
- logo: theme.logo,
341
- favicon: theme.favicon,
342
- logoMark: theme.logoMark
701
+ ok: true,
702
+ rows
343
703
  };
344
704
  }
345
- function colorHex(color) {
346
- if (typeof color === "string") return color;
347
- return color?.hex ?? void 0;
705
+ function roleHasGrant(row, slug) {
706
+ if (row.role === "developer") return true;
707
+ return row.grants.includes(slug);
708
+ }
709
+ function roleHasCapability(row, capability) {
710
+ if (row.role === "developer") return true;
711
+ return CAPABILITY_FEATURES[capability].some((slug) => row.grants.includes(slug));
348
712
  }
349
713
  /**
350
- * A Theme page is a real Global. Hydrate from the store only when this
351
- * page has never been savedotherwise afterRead would overwrite the form.
714
+ * Mint a slug from the name only when the row has none yet. An existing
715
+ * key seed or customstays put so a rename cannot orphan users.
352
716
  */
353
- function hasThemeSlice(doc, child) {
354
- if (!doc) return false;
355
- if (child === "colors") return Boolean(colorHex(doc.colors?.brand?.primary));
356
- if (child === "typography") return Boolean(doc.typography?.body);
357
- if (child === "appearance") return Boolean(doc.appearance?.radius);
358
- return Boolean(doc.logo || doc.favicon || doc.logoMark);
717
+ function applyRoleSlugsFromNames(value) {
718
+ if (!Array.isArray(value)) return value;
719
+ return value.map((item) => {
720
+ if (!item || typeof item !== "object") return item;
721
+ const row = item;
722
+ if (isRoleSlug(row.role)) return item;
723
+ const slug = slugifyRoleName(row.label);
724
+ return isRoleSlug(slug) ? {
725
+ ...row,
726
+ role: slug
727
+ } : item;
728
+ });
729
+ }
730
+ function hasDuplicateRoleNames(value) {
731
+ if (!Array.isArray(value)) return false;
732
+ const seen = /* @__PURE__ */ new Set();
733
+ for (const item of value) {
734
+ if (!item || typeof item !== "object") continue;
735
+ const role = "role" in item && typeof item.role === "string" ? item.role : "";
736
+ const name = ("label" in item && typeof item.label === "string" ? item.label : "").trim() || (isRole(role) ? ROLE_LABELS[role] : "");
737
+ if (!name) continue;
738
+ const key = name.toLowerCase();
739
+ if (seen.has(key)) return true;
740
+ seen.add(key);
741
+ }
742
+ return false;
743
+ }
744
+ function hasInvalidRoleName(value) {
745
+ if (!Array.isArray(value)) return false;
746
+ for (const item of value) {
747
+ if (!item || typeof item !== "object") continue;
748
+ const label = "label" in item && typeof item.label === "string" ? item.label.trim() : "";
749
+ if (!label) continue;
750
+ if (!isRoleName(label)) return true;
751
+ }
752
+ return false;
753
+ }
754
+ function validateRolesMatrix(value) {
755
+ if (hasDuplicateRoleNames(value)) return DUPLICATE_ROLE_NAME_MESSAGE;
756
+ if (hasInvalidRoleName(value)) return INVALID_ROLE_NAME_MESSAGE;
757
+ const parsed = parseRolesMatrix(applyRoleSlugsFromNames(value));
758
+ if (!parsed.ok) return parsed.message;
759
+ return true;
359
760
  }
360
761
  /**
361
- * Save on a Theme page writes that slice onto the hidden `theme` row.
362
- * Other children stay as stored.
762
+ * Developer is exclusive. Admin does not swallow Designer: both store.
763
+ * Unknown slugs (dropped catalogue keys such as `approver`) fall away unless
764
+ * they appear on the matrix.
363
765
  */
364
- async function persistThemeChild(payload, incoming, child) {
365
- const next = publishThemeChild(await payload.findGlobal({
366
- slug: "theme",
367
- draft: false,
368
- overrideAccess: true
369
- }) ?? {}, incoming, child);
370
- await payload.updateGlobal({
371
- slug: THEME_SLUG,
372
- data: next,
373
- draft: false
766
+ function normalizeStoredRoles(value, matrix = DEFAULT_ROLE_MATRIX) {
767
+ if (!Array.isArray(value)) return [];
768
+ const allowed = new Set(matrix.map((row) => row.role));
769
+ const roles = [...new Set(value.filter((entry) => typeof entry === "string" && allowed.has(entry)))];
770
+ if (roles.includes("developer")) return ["developer"];
771
+ return roles;
772
+ }
773
+ function roleSelectOptions(matrix = DEFAULT_ROLE_MATRIX) {
774
+ return matrix.map((row) => ({
775
+ label: roleLabel(row.role, row.label),
776
+ value: row.role
777
+ }));
778
+ }
779
+ /** Grant copy only. Developer names the unreleased catalogue; nothing about MCP or seed. */
780
+ function roleDescription(role, matrix = DEFAULT_ROLE_MATRIX) {
781
+ if (role === "developer") return DEVELOPER_DESCRIPTION;
782
+ const row = matrix.find((entry) => entry.role === role);
783
+ if (!row) return "No capabilities";
784
+ return row.grants.map((slug) => packageFeatureLabel(slug)).join(", ") || "No capabilities";
785
+ }
786
+ /**
787
+ * Seed grants and package labels come back; extra rows stay as they are.
788
+ */
789
+ function resetRolesMatrix(current) {
790
+ const extras = [];
791
+ if (Array.isArray(current)) for (const item of current) {
792
+ if (!item || typeof item !== "object") continue;
793
+ const parsed = readRoleRow(item);
794
+ if ("error" in parsed || isRole(parsed.role)) continue;
795
+ extras.push(parsed);
796
+ }
797
+ return [...defaultRolesFieldValue(), ...extras];
798
+ }
799
+ //#endregion
800
+ //#region src/roles/access.ts
801
+ function isAccessArgs$1(value) {
802
+ return typeof value === "object" && value !== null && "req" in value;
803
+ }
804
+ function storedRoles(user) {
805
+ return Array.isArray(user?.roles) ? user.roles : [];
806
+ }
807
+ function hasRole(user, role) {
808
+ return storedRoles(user).includes(role);
809
+ }
810
+ /** Not a tick. True only when `developer` is stored on the row. */
811
+ function isDeveloper(user) {
812
+ return hasRole(user, "developer");
813
+ }
814
+ function hasCapability(user, capability, matrix = DEFAULT_ROLE_MATRIX) {
815
+ if (isDeveloper(user)) return true;
816
+ return storedRoles(user).some((role) => {
817
+ const row = matrix.find((entry) => entry.role === role);
818
+ return row ? roleHasCapability(row, capability) : false;
374
819
  });
375
- return next;
820
+ }
821
+ function hasGrant(user, slug, matrix = DEFAULT_ROLE_MATRIX) {
822
+ if (isDeveloper(user)) return true;
823
+ return storedRoles(user).some((role) => {
824
+ const row = matrix.find((entry) => entry.role === role);
825
+ return row ? roleHasGrant(row, slug) : false;
826
+ });
827
+ }
828
+ /**
829
+ * Reads the saved Roles Global, falling back to the seed when the row is
830
+ * empty, missing, or unreadable. Always override-access so a Designer
831
+ * evaluating Theme does not have to read Settings → Roles.
832
+ */
833
+ async function getRolesMatrix(req = {}, extras = []) {
834
+ const fallback = seedRoleRows(extras);
835
+ const findGlobal = req.payload?.findGlobal;
836
+ if (typeof findGlobal !== "function") return fallback;
837
+ try {
838
+ const doc = await findGlobal({
839
+ slug: ROLES_SLUG,
840
+ overrideAccess: true,
841
+ req
842
+ });
843
+ const parsed = parseRolesMatrix(doc && typeof doc === "object" && "roles" in doc ? doc.roles : void 0);
844
+ return parsed.ok ? parsed.rows : fallback;
845
+ } catch {
846
+ return fallback;
847
+ }
848
+ }
849
+ function capabilityPredicate(capability) {
850
+ function predicate(userOrArgs, matrix) {
851
+ if (isAccessArgs$1(userOrArgs)) {
852
+ const user = userOrArgs.req.user;
853
+ if (matrix) return hasCapability(user, capability, matrix);
854
+ return getRolesMatrix(userOrArgs.req).then((rows) => hasCapability(user, capability, rows));
855
+ }
856
+ return hasCapability(userOrArgs, capability, matrix ?? DEFAULT_ROLE_MATRIX);
857
+ }
858
+ return predicate;
859
+ }
860
+ const canManageContent = capabilityPredicate("content");
861
+ const canManageBrand = capabilityPredicate("brand");
862
+ const canPublish = capabilityPredicate("publish");
863
+ /** Users grant on any held role (Developer is implicit). */
864
+ const isAdmin = capabilityPredicate("users");
865
+ /** This role id currently has the Users grant, or is Developer. */
866
+ function isPrivilegedRole(role, matrix = DEFAULT_ROLE_MATRIX) {
867
+ if (typeof role !== "string") return false;
868
+ if (role === "developer") return true;
869
+ const row = matrix.find((entry) => entry.role === role);
870
+ return row ? roleHasGrant(row, "users") : false;
871
+ }
872
+ function isAuthenticated(userOrArgs) {
873
+ if (isAccessArgs$1(userOrArgs)) return Boolean(userOrArgs.req.user);
874
+ return Boolean(userOrArgs);
875
+ }
876
+ const isAdminOrSelf = async ({ req }) => {
877
+ if (!req.user) return false;
878
+ if (await isAdmin({ req })) return true;
879
+ return { id: { equals: req.user.id } };
880
+ };
881
+ const authenticatedOrPublished = ({ req: { user } }) => {
882
+ if (isAuthenticated(user)) return true;
883
+ return { _status: { equals: "published" } };
884
+ };
885
+ /**
886
+ * API tab condition. Code-locked to the `developer` slug — not a Features
887
+ * row and not a Role tick a client can grant.
888
+ */
889
+ function isDeveloperTab({ req }) {
890
+ return isDeveloper(req.user);
891
+ }
892
+ /** Access for chrome globals (Features, Better Editor settings). */
893
+ function developerOnlyAccess() {
894
+ return {
895
+ read: ({ req }) => isDeveloper(req.user),
896
+ update: ({ req }) => isDeveloper(req.user)
897
+ };
898
+ }
899
+ /** `admin.hidden`: hide unless the login is Developer. */
900
+ function hideUnlessDeveloper({ user }) {
901
+ return !isDeveloper(user);
902
+ }
903
+ //#endregion
904
+ //#region src/features/access.ts
905
+ function isAccessArgs(value) {
906
+ return typeof value === "object" && value !== null && "req" in value;
907
+ }
908
+ function grantedOnReleased(user, slug, row, matrix) {
909
+ if (!row.released) return false;
910
+ return hasGrant(user, slug, matrix);
911
+ }
912
+ /**
913
+ * True when the feature is released and a held role is granted it.
914
+ * Developer is always allowed. An empty Global falls back to catalogue
915
+ * defaults. A group slug (`pages`, `theme`) is true when any child is.
916
+ */
917
+ function hasFeature(user, slug, features = null, matrix = DEFAULT_ROLE_MATRIX) {
918
+ if (isDeveloper(user)) return true;
919
+ if (!user) return false;
920
+ const rows = features && features.length > 0 ? features : defaultFeaturesFieldValue();
921
+ const leaf = rows.find((entry) => entry.slug === slug);
922
+ if (leaf) return grantedOnReleased(user, slug, leaf, matrix);
923
+ if (!isGroupOnlySlug(slug)) return false;
924
+ return rows.some((entry) => entry.group === slug && grantedOnReleased(user, entry.slug, entry, matrix));
925
+ }
926
+ /**
927
+ * Reads the saved Features Global. `null` means empty or unreadable — callers
928
+ * fall back to catalogue defaults. Always override-access.
929
+ */
930
+ async function getFeaturesMatrix(req = {}) {
931
+ const findGlobal = req.payload?.findGlobal;
932
+ if (typeof findGlobal !== "function") return null;
933
+ try {
934
+ const doc = await findGlobal({
935
+ slug: FEATURES_SLUG,
936
+ overrideAccess: true,
937
+ req
938
+ });
939
+ const parsed = parseFeaturesMatrix(doc && typeof doc === "object" && "features" in doc ? doc.features : void 0);
940
+ return parsed.ok ? parsed.rows : null;
941
+ } catch {
942
+ return null;
943
+ }
944
+ }
945
+ /**
946
+ * Access / nav helper for one catalogue slug. Sync against a passed grid;
947
+ * async when given `req` so it can read both Globals.
948
+ */
949
+ function canUseFeature(slug) {
950
+ function predicate(userOrArgs, features, roles) {
951
+ if (isAccessArgs(userOrArgs)) {
952
+ const user = userOrArgs.req.user;
953
+ if (features !== void 0) return hasFeature(user, slug, features, roles ?? DEFAULT_ROLE_MATRIX);
954
+ return Promise.all([getFeaturesMatrix(userOrArgs.req), getRolesMatrix(userOrArgs.req)]).then(([grid, matrix]) => hasFeature(user, slug, grid, matrix));
955
+ }
956
+ return hasFeature(userOrArgs, slug, features ?? null, roles ?? DEFAULT_ROLE_MATRIX);
957
+ }
958
+ return predicate;
959
+ }
960
+ /**
961
+ * Feature slugs (and group slugs) this login may use. Written onto the
962
+ * user at afterRead so `admin.hidden({ user })` can follow the saved
963
+ * Features and Roles Globals without a `req`.
964
+ */
965
+ async function allowedFeaturesForUser(user, req = {}) {
966
+ const [features, roles] = await Promise.all([getFeaturesMatrix(req), getRolesMatrix(req)]);
967
+ return [...(features && features.length > 0 ? features : defaultFeaturesFieldValue()).map((row) => row.slug), ...FEATURE_GROUPS].filter((slug) => hasFeature(user, slug, features, roles));
968
+ }
969
+ function userHasAllowedFeature(user, slug) {
970
+ if (isDeveloper(user)) return true;
971
+ const allowed = user?.allowedFeatures;
972
+ if (!Array.isArray(allowed)) return false;
973
+ return allowed.includes(slug);
974
+ }
975
+ /** `admin.hidden`: hide when the login cannot use the feature. */
976
+ function hideUnlessFeature(slug) {
977
+ return (args) => {
978
+ const user = args.user ?? null;
979
+ if (isDeveloper(user)) return false;
980
+ if (user && Array.isArray(user.allowedFeatures)) return !userHasAllowedFeature(user, slug);
981
+ if (args.req) {
982
+ const result = canUseFeature(slug)({ req: {
983
+ ...args.req,
984
+ user
985
+ } });
986
+ if (result instanceof Promise) return result.then((ok) => !ok);
987
+ return !result;
988
+ }
989
+ return !canUseFeature(slug)(user);
990
+ };
376
991
  }
377
992
  //#endregion
378
993
  //#region src/theme/global.ts
@@ -677,35 +1292,11 @@ function identityFields(collection) {
677
1292
  }
678
1293
  ];
679
1294
  }
680
- function pageHooks(child) {
681
- return {
682
- beforeChange: [async ({ data, req }) => {
683
- if (req?.payload) await persistThemeChild(req.payload, data, child);
684
- return data;
685
- }],
686
- afterRead: [async ({ doc, req }) => {
687
- if (hasThemeSlice(doc, child)) return doc;
688
- const theme = await req?.payload?.findGlobal({
689
- slug: THEME_SLUG,
690
- draft: false,
691
- depth: child === "identity" ? 1 : 0,
692
- overrideAccess: true
693
- });
694
- if (!theme) return doc;
695
- return {
696
- ...doc,
697
- ...sliceFromTheme(theme, child)
698
- };
699
- }]
700
- };
701
- }
702
1295
  /**
703
- * Standard Payload Globals in an `admin.group: "Theme"` nav section —
704
- * the same pattern as Content and Settings. Each page is its own Global
705
- * (Save, fields, access). A hidden `theme` store is the published read
706
- * model `getPublishedTheme` already knows. Locking is off: these are
707
- * settings forms, not collaborative documents. No draft mode, no preview
708
- * pane.
1296
+ * One Theme Global. Colors, Typography, Appearance, and Identity are
1297
+ * tabs on that document. Save writes the whole form Payload hydrates
1298
+ * every tab from the live row, so an untouched tab does not revert.
1299
+ * Locking is off. No draft mode, no preview pane.
709
1300
  */
710
1301
  function createTheme(options) {
711
1302
  const access = requireAccess$1(options);
@@ -720,59 +1311,49 @@ function createTheme(options) {
720
1311
  const typeFields = typographyFields(seed, fonts, fontsBaseUrl);
721
1312
  const lookFields = appearanceFields(seed);
722
1313
  const markFields = logo ? identityFields(logo.collection) : [];
723
- const store = {
1314
+ return [{
724
1315
  slug: THEME_SLUG,
725
1316
  label: "Theme",
726
1317
  lockDocuments: false,
727
1318
  admin: {
728
- hidden: true,
1319
+ group: "Theme",
1320
+ hidden: hideUnlessFeature("theme"),
729
1321
  hideAPIURL: true,
730
- custom: adminCustom
1322
+ custom: adminCustom,
1323
+ components: { elements: { beforeDocumentControls: [THEME_DOCUMENT_CONTROLS] } }
731
1324
  },
732
1325
  access: {
733
1326
  read: access.read,
734
1327
  update: access.update
735
1328
  },
736
- fields: [
737
- ...colorFields,
738
- ...typeFields,
739
- ...lookFields,
740
- ...markFields
741
- ],
1329
+ fields: [{
1330
+ type: "tabs",
1331
+ tabs: [
1332
+ {
1333
+ label: "Colors",
1334
+ fields: colorFields
1335
+ },
1336
+ {
1337
+ label: "Typography",
1338
+ fields: typeFields
1339
+ },
1340
+ {
1341
+ label: "Appearance",
1342
+ fields: lookFields
1343
+ },
1344
+ ...logo ? [{
1345
+ label: "Identity",
1346
+ fields: markFields
1347
+ }] : []
1348
+ ]
1349
+ }],
742
1350
  endpoints: colorUsageEndpoints(access, colorUsages),
743
1351
  hooks: { afterChange: [async ({ doc }) => {
744
1352
  if (!onPublish) return;
745
1353
  const themeDoc = doc;
746
1354
  await onPublish(themeConfigFromDoc(themeDoc, seed), themeDoc);
747
1355
  }] }
748
- };
749
- function page(slug, label, child, fields, preview) {
750
- return {
751
- slug,
752
- label,
753
- lockDocuments: false,
754
- admin: {
755
- group: "Theme",
756
- hidden: hideUnlessFeature("theme"),
757
- hideAPIURL: true,
758
- custom: adminCustom,
759
- components: preview ? { elements: { beforeDocumentControls: [THEME_DOCUMENT_CONTROLS] } } : void 0
760
- },
761
- access: {
762
- read: access.read,
763
- update: access.update
764
- },
765
- fields,
766
- hooks: pageHooks(child)
767
- };
768
- }
769
- return [
770
- store,
771
- page(THEME_COLORS_SLUG, "Colors", "colors", colorFields, true),
772
- page(THEME_TYPOGRAPHY_SLUG, "Typography", "typography", typeFields, true),
773
- page(THEME_APPEARANCE_SLUG, "Appearance", "appearance", lookFields, true),
774
- ...logo ? [page(THEME_IDENTITY_SLUG, "Identity", "identity", markFields, false)] : []
775
- ];
1356
+ }];
776
1357
  }
777
1358
  //#endregion
778
1359
  //#region src/brand-assets/sanitize.ts
@@ -840,6 +1421,7 @@ function createBrandAssets(options) {
840
1421
  admin: {
841
1422
  group: "Settings",
842
1423
  useAsTitle: "label",
1424
+ hidden: hideUnlessFeature("brand-assets"),
843
1425
  description: "Logo, favicon, and mobile-menu mark. SVG preferred; PNG and ICO allowed."
844
1426
  },
845
1427
  upload: {
@@ -921,6 +1503,52 @@ const THEME_PREVIEW_BREAKPOINTS = [
921
1503
  }
922
1504
  ];
923
1505
  //#endregion
1506
+ //#region src/theme/publish.ts
1507
+ /**
1508
+ * Publishing a child writes that slice only. Other responsibilities stay
1509
+ * as they already are on the stored document. Unsaved form state on a
1510
+ * different child never goes live. A first save (no stored row) still
1511
+ * writes only the named child; `getPublishedTheme` fills the rest from
1512
+ * the seed.
1513
+ */
1514
+ function publishThemeChild(stored, incoming, child) {
1515
+ if (child === "colors") return {
1516
+ ...stored,
1517
+ colors: incoming.colors
1518
+ };
1519
+ if (child === "typography") return {
1520
+ ...stored,
1521
+ typography: incoming.typography
1522
+ };
1523
+ if (child === "appearance") return {
1524
+ ...stored,
1525
+ appearance: incoming.appearance
1526
+ };
1527
+ return {
1528
+ ...stored,
1529
+ logo: incoming.logo,
1530
+ favicon: incoming.favicon,
1531
+ logoMark: incoming.logoMark
1532
+ };
1533
+ }
1534
+ /**
1535
+ * Save on a Theme page writes that slice onto the hidden `theme` row.
1536
+ * Other children stay as stored.
1537
+ */
1538
+ async function persistThemeChild(payload, incoming, child) {
1539
+ const next = publishThemeChild(await payload.findGlobal({
1540
+ slug: "theme",
1541
+ draft: false,
1542
+ overrideAccess: true
1543
+ }) ?? {}, incoming, child);
1544
+ await payload.updateGlobal({
1545
+ slug: THEME_SLUG,
1546
+ data: next,
1547
+ draft: false
1548
+ });
1549
+ return next;
1550
+ }
1551
+ //#endregion
924
1552
  //#region src/roles/fields.ts
925
1553
  const ROLES_MATRIX_FIELD = "@bison-lab/payload-core/admin#RolesMatrixField";
926
1554
  const ROLES_ROW_LABEL = "@bison-lab/payload-core/admin#RolesRowLabel";
@@ -1030,69 +1658,67 @@ const FEATURES_MATRIX_FIELD = "@bison-lab/payload-core/admin#FeaturesMatrixField
1030
1658
  */
1031
1659
  function createFeatures({ extras = [] } = {}) {
1032
1660
  const catalogue = featureCatalogue(extras);
1661
+ const featuresField = {
1662
+ name: "features",
1663
+ type: "array",
1664
+ label: "Features",
1665
+ labels: {
1666
+ singular: "Feature",
1667
+ plural: "Features"
1668
+ },
1669
+ minRows: catalogue.length,
1670
+ maxRows: catalogue.length,
1671
+ required: true,
1672
+ defaultValue: defaultFeaturesFieldValue(extras),
1673
+ validate: (value) => validateFeaturesMatrix(value, extras),
1674
+ hooks: { beforeChange: [({ value }) => {
1675
+ if (!Array.isArray(value)) return value;
1676
+ return stampFeatureCatalogue(value, extras);
1677
+ }] },
1678
+ admin: {
1679
+ components: { Field: FEATURES_MATRIX_FIELD },
1680
+ description: "Release a feature so it can be assigned on Roles. Off keeps it Developer-only.",
1681
+ initCollapsed: false
1682
+ },
1683
+ fields: [
1684
+ {
1685
+ name: "slug",
1686
+ type: "text",
1687
+ label: "Slug",
1688
+ required: true,
1689
+ admin: { readOnly: true }
1690
+ },
1691
+ {
1692
+ name: "label",
1693
+ type: "text",
1694
+ label: "Name",
1695
+ required: true,
1696
+ admin: { readOnly: true }
1697
+ },
1698
+ {
1699
+ name: "group",
1700
+ type: "text",
1701
+ label: "Group",
1702
+ admin: { hidden: true }
1703
+ },
1704
+ {
1705
+ name: "released",
1706
+ type: "checkbox",
1707
+ label: "Released",
1708
+ admin: { hidden: true }
1709
+ }
1710
+ ]
1711
+ };
1033
1712
  return {
1034
1713
  slug: FEATURES_SLUG,
1035
1714
  label: "Features",
1036
1715
  admin: {
1037
1716
  group: "Settings",
1038
- hidden: ({ user }) => !isDeveloper(user),
1717
+ hidden: hideUnlessDeveloper,
1039
1718
  description: "Which features other people may be granted on Roles."
1040
1719
  },
1041
- access: {
1042
- read: ({ req }) => isDeveloper(req.user),
1043
- update: ({ req }) => isDeveloper(req.user)
1044
- },
1045
- fields: [{
1046
- name: "features",
1047
- type: "array",
1048
- label: "Features",
1049
- labels: {
1050
- singular: "Feature",
1051
- plural: "Features"
1052
- },
1053
- minRows: catalogue.length,
1054
- maxRows: catalogue.length,
1055
- required: true,
1056
- defaultValue: defaultFeaturesFieldValue(extras),
1057
- validate: (value) => validateFeaturesMatrix(value, extras),
1058
- hooks: { beforeChange: [({ value }) => {
1059
- if (!Array.isArray(value)) return value;
1060
- return stampFeatureCatalogue(value, extras);
1061
- }] },
1062
- admin: {
1063
- components: { Field: FEATURES_MATRIX_FIELD },
1064
- description: "Release a feature so it can be assigned on Roles. Off keeps it Developer-only.",
1065
- initCollapsed: false
1066
- },
1067
- fields: [
1068
- {
1069
- name: "slug",
1070
- type: "text",
1071
- label: "Slug",
1072
- required: true,
1073
- admin: { readOnly: true }
1074
- },
1075
- {
1076
- name: "label",
1077
- type: "text",
1078
- label: "Name",
1079
- required: true,
1080
- admin: { readOnly: true }
1081
- },
1082
- {
1083
- name: "group",
1084
- type: "text",
1085
- label: "Group",
1086
- admin: { hidden: true }
1087
- },
1088
- {
1089
- name: "released",
1090
- type: "checkbox",
1091
- label: "Released",
1092
- admin: { hidden: true }
1093
- }
1094
- ]
1095
- }]
1720
+ access: developerOnlyAccess(),
1721
+ fields: [featuresField]
1096
1722
  };
1097
1723
  }
1098
1724
  //#endregion
@@ -1189,6 +1815,15 @@ function slugField({ collection, isReserved }) {
1189
1815
  //#endregion
1190
1816
  //#region src/collections/pages.ts
1191
1817
  /**
1818
+ * Content to create or save a draft; Publish to publish or edit a live
1819
+ * page. Without Publish, update is constrained to `_status: draft`.
1820
+ */
1821
+ const pagesUpdate = async (args) => {
1822
+ if (!await Promise.resolve(canUseFeature("content")(args))) return false;
1823
+ if (await Promise.resolve(canPublish(args))) return true;
1824
+ return { _status: { equals: "draft" } };
1825
+ };
1826
+ /**
1192
1827
  * CMS-managed pages: one required hero, then a reorderable body of sections.
1193
1828
  * The CMS owns copy and the order of sections; what a section looks like is
1194
1829
  * code-owned, which is why the blocks are an argument.
@@ -1215,7 +1850,7 @@ function createPages({ heroBlocks, isReservedSlug, layoutBlocks, previewPath, pr
1215
1850
  read: authenticatedOrPublished,
1216
1851
  readVersions: isAuthenticated,
1217
1852
  create: canUseFeature("content"),
1218
- update: canUseFeature("content"),
1853
+ update: pagesUpdate,
1219
1854
  delete: isAdmin
1220
1855
  },
1221
1856
  versions: {
@@ -1374,27 +2009,45 @@ function createUsers({ secureCookies, rolesField, extras = [] }) {
1374
2009
  return arg.result;
1375
2010
  }]
1376
2011
  },
1377
- fields: [{
1378
- type: "row",
1379
- fields: [nameField("firstName"), nameField("lastName")]
1380
- }, {
1381
- name: "roles",
1382
- type: "select",
1383
- hasMany: true,
1384
- required: true,
1385
- defaultValue: ["author"],
1386
- options: roleSelectOptions(fallback),
1387
- admin: {
1388
- components: { Field: rolesField ?? "@bison-lab/payload-core/admin#RolesField" },
1389
- description: "One person can hold several, e.g. Author + Designer."
2012
+ fields: [
2013
+ {
2014
+ type: "row",
2015
+ fields: [nameField("firstName"), nameField("lastName")]
1390
2016
  },
1391
- access: { update: isAdmin },
1392
- validate: rolesValidate(extras),
1393
- hooks: { beforeValidate: [async ({ value, req }) => {
1394
- if (!Array.isArray(value)) return value;
1395
- return normalizeStoredRoles(value, await getRolesMatrix(req, extras));
1396
- }] }
1397
- }]
2017
+ {
2018
+ name: "roles",
2019
+ type: "select",
2020
+ hasMany: true,
2021
+ required: true,
2022
+ defaultValue: ["author"],
2023
+ saveToJWT: true,
2024
+ options: roleSelectOptions(fallback),
2025
+ admin: {
2026
+ components: { Field: rolesField ?? "@bison-lab/payload-core/admin#RolesField" },
2027
+ description: "One person can hold several, e.g. Author + Designer."
2028
+ },
2029
+ access: { update: isAdmin },
2030
+ validate: rolesValidate(extras),
2031
+ hooks: { beforeValidate: [async ({ value, req }) => {
2032
+ if (!Array.isArray(value)) return value;
2033
+ return normalizeStoredRoles(value, await getRolesMatrix(req, extras));
2034
+ }] }
2035
+ },
2036
+ {
2037
+ name: "allowedFeatures",
2038
+ type: "json",
2039
+ admin: {
2040
+ hidden: true,
2041
+ readOnly: true
2042
+ },
2043
+ access: { update: () => false },
2044
+ saveToJWT: true,
2045
+ hooks: { afterRead: [async ({ siblingData, data, req }) => allowedFeaturesForUser({
2046
+ id: siblingData?.id ?? data?.id,
2047
+ roles: siblingData?.roles ?? data?.roles
2048
+ }, req ?? {})] }
2049
+ }
2050
+ ]
1398
2051
  };
1399
2052
  }
1400
2053
  //#endregion
@@ -1470,6 +2123,6 @@ const adminOnlyApiTab = definePlugin({
1470
2123
  })
1471
2124
  });
1472
2125
  //#endregion
1473
- export { BRAND_ASSETS_MIME_TYPES, BRAND_ASSETS_SLUG, CAPABILITIES, CAPABILITY_FEATURES, DEFAULT_ROLE_GRANTS, DEFAULT_ROLE_MATRIX, DESCRIPTION_LENGTH, DEVELOPER_DESCRIPTION, DUPLICATE_ROLE_NAME_MESSAGE, DUPLICATE_ROLE_SLUG_MESSAGE, FEATURES_MATRIX_FIELD, FEATURES_SLUG, FEATURE_GROUPS, FEATURE_GROUP_LABELS, INVALID_ROLE_NAME_MESSAGE, LAST_ADMIN_DELETE_MESSAGE, LAST_ADMIN_DEMOTE_MESSAGE, LAST_USERS_TICK_MESSAGE, LOOK_FIELD, MISSING_PACKAGE_FEATURES_MESSAGE, MISSING_SEED_ROLES_MESSAGE, PACKAGE_FEATURES, PACKAGE_FEATURE_SLUGS, PAGE_EDITOR_SYSTEM_KEYS, ROLES, ROLES_FIELD, ROLES_FIELD_DESCRIPTION, ROLES_GLOBAL_DESCRIPTION, ROLES_GRANTS_FIELD, ROLES_MATRIX_FIELD, ROLES_ROW_LABEL, ROLES_SLUG, ROLE_LABELS, ROLE_NAME_FIELD, ROLE_SLUG_FIELD, SHARE_IMAGE_SIZE, SYSTEM_COLOR_KEYS, THEME_APPEARANCE_FIELD, THEME_APPEARANCE_SLUG, THEME_COLORS_SLUG, THEME_COLOR_FIELD, THEME_COLOR_SCALE_FIELD, THEME_CONTRAST_REPORT, THEME_DOCUMENT_CONTROLS, THEME_FEATURE_SLUGS, THEME_FONT_FIELD, THEME_GREY_SCALE_FIELD, THEME_IDENTITY_FALLBACK, THEME_IDENTITY_SLUG, THEME_LIBRARY_FIELD, THEME_PAIRING_FIELD, THEME_PREVIEW_BREAKPOINTS, THEME_PUBLISH_FIELD, THEME_SAVE_BUTTON, THEME_SECTION_HEADING, THEME_SLUG, THEME_TYPOGRAPHY_SLUG, adminOnlyApiTab, applyRoleSlugsFromNames, authenticatedOrPublished, canManageBrand, canManageContent, canPublish, canUseFeature, colorTokenField, createBrandAssets, createFeatures, createMedia, createPages, createRoles, createTheme, createUsers, defaultFeaturesFieldValue, defaultRolesFieldValue, deleteLibraryColor, documentTitle, featureCatalogue, featureGroupLabel, findColorTokens, findColorUsages, firstImageIn, getFeaturesMatrix, getRolesMatrix, hasCapability, hasFeature, hasGrant, hasRole, hideUnlessFeature, isAdmin, isAdminOrSelf, isAuthenticated, isDeveloper, isDeveloperTab, isFeatureGroupId, isFeatureSlug, isLockedFeature, isPackageFeatureSlug, isPrivilegedRole, isRole, isRoleName, isRoleSlug, lookField, noIndexField, normalizeSlug, normalizeStoredRoles, pageEditorLooks, pageEditorTokens, parseFeaturesMatrix, parseRolesMatrix, persistThemeChild, publishThemeChild, resetRolesMatrix, resolveThemeIdentity, rewriteColorToken, rewriteColorTokens, rewriteColorUsages, roleDescription, roleLabel, roleSelectOptions, sanitizeRoleNameInput, sanitizeSvg, seedFeatures, seedRoleRows, seedRoles, seedTheme, seoPlugin, slugField, slugifyRoleName, stampFeatureCatalogue, storedRoles, themeColorKeys, themeLibraryFromDoc, titleTemplate, truncateAtWord, validateFeaturesMatrix, validateRolesMatrix };
2126
+ export { BRAND_ASSETS_MIME_TYPES, BRAND_ASSETS_SLUG, CAPABILITIES, CAPABILITY_FEATURES, DEFAULT_ROLE_GRANTS, DEFAULT_ROLE_MATRIX, DESCRIPTION_LENGTH, DEVELOPER_DESCRIPTION, DUPLICATE_ROLE_NAME_MESSAGE, DUPLICATE_ROLE_SLUG_MESSAGE, FEATURES_MATRIX_FIELD, FEATURES_SLUG, FEATURE_GROUPS, FEATURE_GROUP_LABELS, INVALID_ROLE_NAME_MESSAGE, LAST_ADMIN_DELETE_MESSAGE, LAST_ADMIN_DEMOTE_MESSAGE, LAST_USERS_TICK_MESSAGE, LOOK_FIELD, MISSING_PACKAGE_FEATURES_MESSAGE, MISSING_SEED_ROLES_MESSAGE, PACKAGE_FEATURES, PACKAGE_FEATURE_SLUGS, PAGE_EDITOR_SYSTEM_KEYS, ROLES, ROLES_FIELD, ROLES_FIELD_DESCRIPTION, ROLES_GLOBAL_DESCRIPTION, ROLES_GRANTS_FIELD, ROLES_MATRIX_FIELD, ROLES_ROW_LABEL, ROLES_SLUG, ROLE_LABELS, ROLE_NAME_FIELD, ROLE_SLUG_FIELD, SHARE_IMAGE_SIZE, SYSTEM_COLOR_KEYS, THEME_APPEARANCE_FIELD, THEME_APPEARANCE_SLUG, THEME_COLORS_SLUG, THEME_COLOR_FIELD, THEME_COLOR_SCALE_FIELD, THEME_CONTRAST_REPORT, THEME_DOCUMENT_CONTROLS, THEME_FEATURE_SLUGS, THEME_FONT_FIELD, THEME_GREY_SCALE_FIELD, THEME_IDENTITY_FALLBACK, THEME_IDENTITY_SLUG, THEME_LIBRARY_FIELD, THEME_PAIRING_FIELD, THEME_PREVIEW_BREAKPOINTS, THEME_PUBLISH_FIELD, THEME_SAVE_BUTTON, THEME_SECTION_HEADING, THEME_SLUG, THEME_TYPOGRAPHY_SLUG, adminOnlyApiTab, allowedFeaturesForUser, applyRoleSlugsFromNames, authenticatedOrPublished, canManageBrand, canManageContent, canPublish, canUseFeature, colorTokenField, createBrandAssets, createFeatures, createMedia, createPages, createRoles, createTheme, createUsers, defaultFeaturesFieldValue, defaultRolesFieldValue, deleteLibraryColor, developerOnlyAccess, documentTitle, featureCatalogue, featureGroupLabel, findColorTokens, findColorUsages, firstImageIn, getFeaturesMatrix, getRolesMatrix, hasCapability, hasFeature, hasGrant, hasRole, hideUnlessDeveloper, hideUnlessFeature, isAdmin, isAdminOrSelf, isAuthenticated, isDeveloper, isDeveloperTab, isFeatureGroupId, isFeatureSlug, isLockedFeature, isPackageFeatureSlug, isPrivilegedRole, isRole, isRoleName, isRoleSlug, lookField, noIndexField, normalizeSlug, normalizeStoredRoles, pageEditorLooks, pageEditorTokens, parseFeaturesMatrix, parseRolesMatrix, persistThemeChild, publishThemeChild, resetRolesMatrix, resolveThemeIdentity, rewriteColorToken, rewriteColorTokens, rewriteColorUsages, roleDescription, roleLabel, roleSelectOptions, sanitizeRoleNameInput, sanitizeSvg, seedFeatures, seedRoleRows, seedRoles, seedTheme, seoPlugin, slugField, slugifyRoleName, stampFeatureCatalogue, storedRoles, themeColorKeys, themeLibraryFromDoc, titleTemplate, truncateAtWord, validateFeaturesMatrix, validateRolesMatrix };
1474
2127
 
1475
2128
  //# sourceMappingURL=index.mjs.map