@bison-lab/payload-core 3.13.0 → 3.15.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
@@ -303,75 +303,664 @@ async function readJson(req) {
303
303
  return null;
304
304
  }
305
305
  //#endregion
306
- //#region src/theme/publish.ts
306
+ //#region src/features/types.ts
307
+ const FEATURES_SLUG = "features";
308
+ const FEATURE_GROUPS = [
309
+ "pages",
310
+ "media",
311
+ "theme",
312
+ "users"
313
+ ];
314
+ const FEATURE_GROUP_LABELS = {
315
+ pages: "Pages",
316
+ media: "Media",
317
+ theme: "Theme",
318
+ users: "Users"
319
+ };
320
+ const PACKAGE_FEATURE_SLUGS = [
321
+ "content",
322
+ "publish",
323
+ "media",
324
+ "theme-colors",
325
+ "theme-typography",
326
+ "theme-appearance",
327
+ "theme-identity",
328
+ "brand-assets",
329
+ "users",
330
+ "roles"
331
+ ];
307
332
  /**
308
- * Publishing a child writes that slice only. Other responsibilities stay
309
- * as they already are on the stored document. Unsaved form state on a
310
- * different child never goes live. A first save (no stored row) still
311
- * writes only the named child; `getPublishedTheme` fills the rest from
312
- * the seed.
333
+ * Package catalogue. Features itself is not a row that screen is
334
+ * Developer-only in code. Empty Global falls back to these defaults.
313
335
  */
314
- function publishThemeChild(stored, incoming, child) {
315
- if (child === "colors") return {
316
- ...stored,
317
- colors: incoming.colors
336
+ const PACKAGE_FEATURES = [
337
+ {
338
+ slug: "content",
339
+ label: "Content",
340
+ group: "pages",
341
+ defaultReleased: true
342
+ },
343
+ {
344
+ slug: "publish",
345
+ label: "Publish",
346
+ group: "pages",
347
+ defaultReleased: true
348
+ },
349
+ {
350
+ slug: "media",
351
+ label: "Media",
352
+ group: "media",
353
+ defaultReleased: true
354
+ },
355
+ {
356
+ slug: "theme-colors",
357
+ label: "Colors",
358
+ group: "theme",
359
+ defaultReleased: true
360
+ },
361
+ {
362
+ slug: "theme-typography",
363
+ label: "Typography",
364
+ group: "theme",
365
+ defaultReleased: true
366
+ },
367
+ {
368
+ slug: "theme-appearance",
369
+ label: "Appearance",
370
+ group: "theme",
371
+ defaultReleased: true
372
+ },
373
+ {
374
+ slug: "theme-identity",
375
+ label: "Identity",
376
+ group: "theme",
377
+ defaultReleased: true
378
+ },
379
+ {
380
+ slug: "brand-assets",
381
+ label: "Brand assets",
382
+ group: "theme",
383
+ defaultReleased: true
384
+ },
385
+ {
386
+ slug: "users",
387
+ label: "Users",
388
+ group: "users",
389
+ defaultReleased: true
390
+ },
391
+ {
392
+ slug: "roles",
393
+ label: "Roles",
394
+ group: "users",
395
+ defaultReleased: true
396
+ }
397
+ ];
398
+ function isFeatureGroupId(value) {
399
+ return typeof value === "string" && FEATURE_GROUPS.includes(value);
400
+ }
401
+ function isPackageFeatureSlug(value) {
402
+ return typeof value === "string" && PACKAGE_FEATURE_SLUGS.includes(value);
403
+ }
404
+ function isFeatureSlug(value) {
405
+ return typeof value === "string" && /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(value);
406
+ }
407
+ /** @deprecated Locks are gone; the release switch is the valve. */
408
+ function isLockedFeature(_slug, _locked) {
409
+ return false;
410
+ }
411
+ //#endregion
412
+ //#region src/features/matrix.ts
413
+ const MISSING_PACKAGE_FEATURES_MESSAGE = "Features must include Content, Publish, Media, Theme screens, Brand assets, Users, and Roles.";
414
+ function featureCatalogue(extras = []) {
415
+ return [...PACKAGE_FEATURES.map((feature) => ({ ...feature })), ...extras.map((extra) => ({
416
+ slug: extra.slug,
417
+ label: extra.label,
418
+ group: extra.group ?? "users",
419
+ defaultReleased: extra.defaultReleased ?? false
420
+ }))];
421
+ }
422
+ function defaultFeaturesFieldValue(extras = []) {
423
+ return featureCatalogue(extras).map((feature) => ({
424
+ id: feature.slug,
425
+ slug: feature.slug,
426
+ label: feature.label,
427
+ group: feature.group,
428
+ released: feature.defaultReleased
429
+ }));
430
+ }
431
+ function stampFeatureCatalogue(value, extras = []) {
432
+ const bySlug = new Map(featureCatalogue(extras).map((feature) => [feature.slug, feature]));
433
+ if (!Array.isArray(value)) return defaultFeaturesFieldValue(extras);
434
+ return value.flatMap((item) => {
435
+ if (!item || typeof item !== "object") return [];
436
+ const slug = "slug" in item && typeof item.slug === "string" ? item.slug : "";
437
+ const feature = bySlug.get(slug);
438
+ if (!feature) return [];
439
+ return [{
440
+ id: feature.slug,
441
+ slug: feature.slug,
442
+ label: feature.label,
443
+ group: feature.group,
444
+ released: Boolean("released" in item && item.released)
445
+ }];
446
+ });
447
+ }
448
+ function parseFeaturesMatrix(value) {
449
+ if (!Array.isArray(value) || value.length === 0) return {
450
+ ok: false,
451
+ message: MISSING_PACKAGE_FEATURES_MESSAGE
318
452
  };
319
- if (child === "typography") return {
320
- ...stored,
321
- typography: incoming.typography
453
+ const rows = [];
454
+ const seen = /* @__PURE__ */ new Set();
455
+ for (const item of value) {
456
+ if (!item || typeof item !== "object") continue;
457
+ const slug = "slug" in item ? item.slug : void 0;
458
+ if (!isFeatureSlug(slug) || seen.has(slug)) continue;
459
+ seen.add(slug);
460
+ const pack = PACKAGE_FEATURES.find((feature) => feature.slug === slug);
461
+ const label = "label" in item && typeof item.label === "string" ? item.label : pack?.label ?? slug;
462
+ const group = "group" in item && isFeatureGroupId(item.group) ? item.group : pack?.group ?? "users";
463
+ rows.push({
464
+ id: slug,
465
+ slug,
466
+ label,
467
+ group,
468
+ released: Boolean("released" in item && item.released)
469
+ });
470
+ }
471
+ if (PACKAGE_FEATURES.some((feature) => !seen.has(feature.slug))) return {
472
+ ok: false,
473
+ message: MISSING_PACKAGE_FEATURES_MESSAGE
322
474
  };
323
- if (child === "appearance") return {
324
- ...stored,
325
- appearance: incoming.appearance
475
+ return {
476
+ ok: true,
477
+ rows
326
478
  };
479
+ }
480
+ function validateFeaturesMatrix(value, extras = []) {
481
+ const parsed = parseFeaturesMatrix(value);
482
+ if (!parsed.ok) return parsed.message;
483
+ const allowed = new Set(featureCatalogue(extras).map((feature) => feature.slug));
484
+ if (parsed.rows.some((row) => !allowed.has(row.slug))) return MISSING_PACKAGE_FEATURES_MESSAGE;
485
+ return true;
486
+ }
487
+ function featureGroupLabel(group) {
488
+ return isFeatureGroupId(group) ? FEATURE_GROUP_LABELS[group] : group;
489
+ }
490
+ function packageFeatureLabel(slug, extras = []) {
491
+ const pack = PACKAGE_FEATURES.find((feature) => feature.slug === slug);
492
+ if (pack) return pack.label;
493
+ return extras.find((feature) => feature.slug === slug)?.label ?? slug;
494
+ }
495
+ function isGroupOnlySlug(slug) {
496
+ return isFeatureGroupId(slug) && !PACKAGE_FEATURES.some((feature) => feature.slug === slug);
497
+ }
498
+ //#endregion
499
+ //#region src/roles/types.ts
500
+ /**
501
+ * The Roles Global a site's generated types will describe. Optional and
502
+ * nullable, no index signature: a generated Global is assignable to this,
503
+ * never the reverse.
504
+ */
505
+ const ROLES = [
506
+ "developer",
507
+ "admin",
508
+ "designer",
509
+ "author"
510
+ ];
511
+ const ROLE_LABELS = {
512
+ developer: "Developer",
513
+ admin: "Admin",
514
+ designer: "Designer",
515
+ author: "Author"
516
+ };
517
+ /** Capability aliases over feature grants. Brand is any Theme-group leaf. */
518
+ const CAPABILITIES = [
519
+ "content",
520
+ "brand",
521
+ "publish",
522
+ "users"
523
+ ];
524
+ const THEME_FEATURE_SLUGS = [
525
+ "theme-colors",
526
+ "theme-typography",
527
+ "theme-appearance",
528
+ "theme-identity",
529
+ "brand-assets"
530
+ ];
531
+ const CAPABILITY_FEATURES = {
532
+ content: ["content"],
533
+ publish: ["publish"],
534
+ users: ["users"],
535
+ brand: THEME_FEATURE_SLUGS
536
+ };
537
+ const DEFAULT_ROLE_GRANTS = {
538
+ developer: [],
539
+ admin: [
540
+ "content",
541
+ "publish",
542
+ "media",
543
+ "users",
544
+ "roles"
545
+ ],
546
+ designer: [
547
+ "content",
548
+ "media",
549
+ ...THEME_FEATURE_SLUGS,
550
+ "users",
551
+ "roles"
552
+ ],
553
+ author: ["content", "media"]
554
+ };
555
+ function isRole(value) {
556
+ return typeof value === "string" && ROLES.includes(value);
557
+ }
558
+ /** Lowercase slug from a letters-and-spaces name: `designer`, `the-designer`. */
559
+ function isRoleSlug(value) {
560
+ return typeof value === "string" && /^[a-z]+(-[a-z]+)*$/.test(value);
561
+ }
562
+ /** Display name: letters and single spaces only. `The Greatest Designer`. */
563
+ function isRoleName(value) {
564
+ return typeof value === "string" && /^[A-Za-z]+(?: [A-Za-z]+)*$/.test(value.trim());
565
+ }
566
+ /** Strip digits and punctuation as the name is typed. Trailing space stays. */
567
+ function sanitizeRoleNameInput(value) {
568
+ return value.replace(/[^A-Za-z ]/g, "").replace(/ {2,}/g, " ");
569
+ }
570
+ /** Name → stored key. `The Greatest Designer` → `the-greatest-designer`. */
571
+ function slugifyRoleName(value) {
572
+ if (typeof value !== "string") return "";
573
+ return value.trim().toLowerCase().replace(/[^a-z]+/g, "-").replace(/^-|-$/g, "");
574
+ }
575
+ function roleLabel(role, label) {
576
+ const trimmed = typeof label === "string" ? label.trim() : "";
577
+ if (trimmed) return trimmed;
578
+ return isRole(role) ? ROLE_LABELS[role] : role;
579
+ }
580
+ function defaultGrantsForRole(role, extraGrants) {
581
+ if (isRole(role)) return [...DEFAULT_ROLE_GRANTS[role]];
582
+ return extraGrants ? [...extraGrants] : [];
583
+ }
584
+ //#endregion
585
+ //#region src/roles/matrix.ts
586
+ const ROLES_SLUG = "roles";
587
+ const ROLES_GLOBAL_DESCRIPTION = "Who may do what. Drag to change rank.";
588
+ const ROLES_FIELD_DESCRIPTION = "Drag to change rank. Ticks are features released on Features. Developer has the whole catalogue.";
589
+ /**
590
+ * Default rank and grants. Developer is implicit (empty grants). Brand
591
+ * screens default to Designer.
592
+ */
593
+ const DEFAULT_ROLE_MATRIX = ROLES.map((role) => ({
594
+ role,
595
+ grants: defaultGrantsForRole(role)
596
+ }));
597
+ const DEVELOPER_DESCRIPTION = "Everything, including features not released for assignment.";
598
+ const LAST_USERS_TICK_MESSAGE = "Keep Users granted on at least one of Admin or Developer.";
599
+ const MISSING_SEED_ROLES_MESSAGE = "Roles must include Developer, Admin, Designer, and Author.";
600
+ const DUPLICATE_ROLE_SLUG_MESSAGE = "Each role needs a unique slug.";
601
+ const DUPLICATE_ROLE_NAME_MESSAGE = "Each role needs a unique name.";
602
+ const INVALID_ROLE_NAME_MESSAGE = "Use letters and spaces only.";
603
+ function uniqueSlugs(values) {
604
+ const slugs = [];
605
+ for (const value of values) if (typeof value === "string" && isFeatureSlug(value) && !slugs.includes(value)) slugs.push(value);
606
+ return slugs;
607
+ }
608
+ /** Read stored grants, or rebuild them from the old capability ticks. */
609
+ function grantsFromStoredRole(item) {
610
+ if ("grants" in item && Array.isArray(item.grants)) return uniqueSlugs(item.grants);
611
+ const grants = [];
612
+ if ("content" in item && item.content) grants.push("content", "media");
613
+ if ("publish" in item && item.publish) grants.push("publish");
614
+ if ("users" in item && item.users) grants.push("users", "roles");
615
+ if ("brand" in item && item.brand) grants.push(...THEME_FEATURE_SLUGS);
616
+ return uniqueSlugs(grants);
617
+ }
618
+ function seedRoleRows(extras = []) {
619
+ return [...DEFAULT_ROLE_MATRIX.map((row) => ({
620
+ ...row,
621
+ grants: [...row.grants],
622
+ label: isRole(row.role) ? ROLE_LABELS[row.role] : row.role
623
+ })), ...extras.map((extra) => ({
624
+ role: extra.role,
625
+ label: extra.label,
626
+ grants: extra.grants ? [...extra.grants] : []
627
+ }))];
628
+ }
629
+ function defaultRolesFieldValue(extras = []) {
630
+ return seedRoleRows(extras).map((row) => ({
631
+ id: row.role,
632
+ ...row
633
+ }));
634
+ }
635
+ function readRoleRow(item) {
636
+ const role = "role" in item ? item.role : void 0;
637
+ if (typeof role !== "string" || role.trim() === "") return { error: DUPLICATE_ROLE_SLUG_MESSAGE };
638
+ if (!isRoleSlug(role)) return { error: DUPLICATE_ROLE_SLUG_MESSAGE };
327
639
  return {
328
- ...stored,
329
- logo: incoming.logo,
330
- favicon: incoming.favicon,
331
- logoMark: incoming.logoMark
640
+ id: role,
641
+ role,
642
+ label: roleLabel(role, "label" in item && typeof item.label === "string" ? item.label : void 0),
643
+ grants: grantsFromStoredRole(item)
332
644
  };
333
645
  }
334
- function sliceFromTheme(theme, child) {
335
- if (child === "colors") return { colors: theme.colors };
336
- if (child === "typography") return { typography: theme.typography };
337
- if (child === "appearance") return { appearance: theme.appearance };
646
+ function parseRolesMatrix(value) {
647
+ if (!Array.isArray(value)) return {
648
+ ok: false,
649
+ message: MISSING_SEED_ROLES_MESSAGE
650
+ };
651
+ const rows = [];
652
+ const seen = /* @__PURE__ */ new Set();
653
+ for (const item of value) {
654
+ if (!item || typeof item !== "object") continue;
655
+ const role = "role" in item ? item.role : void 0;
656
+ if (typeof role !== "string" || role.trim() === "") continue;
657
+ const parsed = readRoleRow(item);
658
+ if ("error" in parsed) return {
659
+ ok: false,
660
+ message: parsed.error
661
+ };
662
+ if (seen.has(parsed.role)) return {
663
+ ok: false,
664
+ message: DUPLICATE_ROLE_SLUG_MESSAGE
665
+ };
666
+ seen.add(parsed.role);
667
+ rows.push(parsed);
668
+ }
669
+ if (ROLES.some((role) => !seen.has(role))) return {
670
+ ok: false,
671
+ message: MISSING_SEED_ROLES_MESSAGE
672
+ };
338
673
  return {
339
- logo: theme.logo,
340
- favicon: theme.favicon,
341
- logoMark: theme.logoMark
674
+ ok: true,
675
+ rows
342
676
  };
343
677
  }
344
- function colorHex(color) {
345
- if (typeof color === "string") return color;
346
- return color?.hex ?? void 0;
678
+ function roleHasGrant(row, slug) {
679
+ if (row.role === "developer") return true;
680
+ return row.grants.includes(slug);
681
+ }
682
+ function roleHasCapability(row, capability) {
683
+ if (row.role === "developer") return true;
684
+ return CAPABILITY_FEATURES[capability].some((slug) => row.grants.includes(slug));
347
685
  }
348
686
  /**
349
- * A Theme page is a real Global. Hydrate from the store only when this
350
- * page has never been savedotherwise afterRead would overwrite the form.
687
+ * Mint a slug from the name only when the row has none yet. An existing
688
+ * key seed or customstays put so a rename cannot orphan users.
351
689
  */
352
- function hasThemeSlice(doc, child) {
353
- if (!doc) return false;
354
- if (child === "colors") return Boolean(colorHex(doc.colors?.brand?.primary));
355
- if (child === "typography") return Boolean(doc.typography?.body);
356
- if (child === "appearance") return Boolean(doc.appearance?.radius);
357
- return Boolean(doc.logo || doc.favicon || doc.logoMark);
690
+ function applyRoleSlugsFromNames(value) {
691
+ if (!Array.isArray(value)) return value;
692
+ return value.map((item) => {
693
+ if (!item || typeof item !== "object") return item;
694
+ const row = item;
695
+ if (isRoleSlug(row.role)) return item;
696
+ const slug = slugifyRoleName(row.label);
697
+ return isRoleSlug(slug) ? {
698
+ ...row,
699
+ role: slug
700
+ } : item;
701
+ });
702
+ }
703
+ function hasDuplicateRoleNames(value) {
704
+ if (!Array.isArray(value)) return false;
705
+ const seen = /* @__PURE__ */ new Set();
706
+ for (const item of value) {
707
+ if (!item || typeof item !== "object") continue;
708
+ const role = "role" in item && typeof item.role === "string" ? item.role : "";
709
+ const name = ("label" in item && typeof item.label === "string" ? item.label : "").trim() || (isRole(role) ? ROLE_LABELS[role] : "");
710
+ if (!name) continue;
711
+ const key = name.toLowerCase();
712
+ if (seen.has(key)) return true;
713
+ seen.add(key);
714
+ }
715
+ return false;
716
+ }
717
+ function hasInvalidRoleName(value) {
718
+ if (!Array.isArray(value)) return false;
719
+ for (const item of value) {
720
+ if (!item || typeof item !== "object") continue;
721
+ const label = "label" in item && typeof item.label === "string" ? item.label.trim() : "";
722
+ if (!label) continue;
723
+ if (!isRoleName(label)) return true;
724
+ }
725
+ return false;
726
+ }
727
+ function validateRolesMatrix(value) {
728
+ if (hasDuplicateRoleNames(value)) return DUPLICATE_ROLE_NAME_MESSAGE;
729
+ if (hasInvalidRoleName(value)) return INVALID_ROLE_NAME_MESSAGE;
730
+ const parsed = parseRolesMatrix(applyRoleSlugsFromNames(value));
731
+ if (!parsed.ok) return parsed.message;
732
+ return true;
358
733
  }
359
734
  /**
360
- * Save on a Theme page writes that slice onto the hidden `theme` row.
361
- * Other children stay as stored.
735
+ * Developer is exclusive. Admin does not swallow Designer: both store.
736
+ * Unknown slugs (dropped catalogue keys such as `approver`) fall away unless
737
+ * they appear on the matrix.
362
738
  */
363
- async function persistThemeChild(payload, incoming, child) {
364
- const next = publishThemeChild(await payload.findGlobal({
365
- slug: "theme",
366
- draft: false,
367
- overrideAccess: true
368
- }) ?? {}, incoming, child);
369
- await payload.updateGlobal({
370
- slug: THEME_SLUG,
371
- data: next,
372
- draft: false
739
+ function normalizeStoredRoles(value, matrix = DEFAULT_ROLE_MATRIX) {
740
+ if (!Array.isArray(value)) return [];
741
+ const allowed = new Set(matrix.map((row) => row.role));
742
+ const roles = [...new Set(value.filter((entry) => typeof entry === "string" && allowed.has(entry)))];
743
+ if (roles.includes("developer")) return ["developer"];
744
+ return roles;
745
+ }
746
+ function roleSelectOptions(matrix = DEFAULT_ROLE_MATRIX) {
747
+ return matrix.map((row) => ({
748
+ label: roleLabel(row.role, row.label),
749
+ value: row.role
750
+ }));
751
+ }
752
+ /** Grant copy only. Developer names the unreleased catalogue; nothing about MCP or seed. */
753
+ function roleDescription(role, matrix = DEFAULT_ROLE_MATRIX) {
754
+ if (role === "developer") return DEVELOPER_DESCRIPTION;
755
+ const row = matrix.find((entry) => entry.role === role);
756
+ if (!row) return "No capabilities";
757
+ return row.grants.map((slug) => packageFeatureLabel(slug)).join(", ") || "No capabilities";
758
+ }
759
+ /**
760
+ * Seed grants and package labels come back; extra rows stay as they are.
761
+ */
762
+ function resetRolesMatrix(current) {
763
+ const extras = [];
764
+ if (Array.isArray(current)) for (const item of current) {
765
+ if (!item || typeof item !== "object") continue;
766
+ const parsed = readRoleRow(item);
767
+ if ("error" in parsed || isRole(parsed.role)) continue;
768
+ extras.push(parsed);
769
+ }
770
+ return [...defaultRolesFieldValue(), ...extras];
771
+ }
772
+ //#endregion
773
+ //#region src/roles/access.ts
774
+ function isAccessArgs$1(value) {
775
+ return typeof value === "object" && value !== null && "req" in value;
776
+ }
777
+ function storedRoles(user) {
778
+ return Array.isArray(user?.roles) ? user.roles : [];
779
+ }
780
+ function hasRole(user, role) {
781
+ return storedRoles(user).includes(role);
782
+ }
783
+ /** Not a tick. True only when `developer` is stored on the row. */
784
+ function isDeveloper(user) {
785
+ return hasRole(user, "developer");
786
+ }
787
+ function hasCapability(user, capability, matrix = DEFAULT_ROLE_MATRIX) {
788
+ if (isDeveloper(user)) return true;
789
+ return storedRoles(user).some((role) => {
790
+ const row = matrix.find((entry) => entry.role === role);
791
+ return row ? roleHasCapability(row, capability) : false;
373
792
  });
374
- return next;
793
+ }
794
+ function hasGrant(user, slug, matrix = DEFAULT_ROLE_MATRIX) {
795
+ if (isDeveloper(user)) return true;
796
+ return storedRoles(user).some((role) => {
797
+ const row = matrix.find((entry) => entry.role === role);
798
+ return row ? roleHasGrant(row, slug) : false;
799
+ });
800
+ }
801
+ /**
802
+ * Reads the saved Roles Global, falling back to the seed when the row is
803
+ * empty, missing, or unreadable. Always override-access so a Designer
804
+ * evaluating Theme does not have to read Settings → Roles.
805
+ */
806
+ async function getRolesMatrix(req = {}, extras = []) {
807
+ const fallback = seedRoleRows(extras);
808
+ const findGlobal = req.payload?.findGlobal;
809
+ if (typeof findGlobal !== "function") return fallback;
810
+ try {
811
+ const doc = await findGlobal({
812
+ slug: ROLES_SLUG,
813
+ overrideAccess: true,
814
+ req
815
+ });
816
+ const parsed = parseRolesMatrix(doc && typeof doc === "object" && "roles" in doc ? doc.roles : void 0);
817
+ return parsed.ok ? parsed.rows : fallback;
818
+ } catch {
819
+ return fallback;
820
+ }
821
+ }
822
+ function capabilityPredicate(capability) {
823
+ function predicate(userOrArgs, matrix) {
824
+ if (isAccessArgs$1(userOrArgs)) {
825
+ const user = userOrArgs.req.user;
826
+ if (matrix) return hasCapability(user, capability, matrix);
827
+ return getRolesMatrix(userOrArgs.req).then((rows) => hasCapability(user, capability, rows));
828
+ }
829
+ return hasCapability(userOrArgs, capability, matrix ?? DEFAULT_ROLE_MATRIX);
830
+ }
831
+ return predicate;
832
+ }
833
+ const canManageContent = capabilityPredicate("content");
834
+ const canManageBrand = capabilityPredicate("brand");
835
+ const canPublish = capabilityPredicate("publish");
836
+ /** Users grant on any held role (Developer is implicit). */
837
+ const isAdmin = capabilityPredicate("users");
838
+ /** This role id currently has the Users grant, or is Developer. */
839
+ function isPrivilegedRole(role, matrix = DEFAULT_ROLE_MATRIX) {
840
+ if (typeof role !== "string") return false;
841
+ if (role === "developer") return true;
842
+ const row = matrix.find((entry) => entry.role === role);
843
+ return row ? roleHasGrant(row, "users") : false;
844
+ }
845
+ function isAuthenticated(userOrArgs) {
846
+ if (isAccessArgs$1(userOrArgs)) return Boolean(userOrArgs.req.user);
847
+ return Boolean(userOrArgs);
848
+ }
849
+ const isAdminOrSelf = async ({ req }) => {
850
+ if (!req.user) return false;
851
+ if (await isAdmin({ req })) return true;
852
+ return { id: { equals: req.user.id } };
853
+ };
854
+ const authenticatedOrPublished = ({ req: { user } }) => {
855
+ if (isAuthenticated(user)) return true;
856
+ return { _status: { equals: "published" } };
857
+ };
858
+ /**
859
+ * API tab condition. Code-locked to the `developer` slug — not a Features
860
+ * row and not a Role tick a client can grant.
861
+ */
862
+ function isDeveloperTab({ req }) {
863
+ return isDeveloper(req.user);
864
+ }
865
+ /** Access for chrome globals (Features, Better Editor settings). */
866
+ function developerOnlyAccess() {
867
+ return {
868
+ read: ({ req }) => isDeveloper(req.user),
869
+ update: ({ req }) => isDeveloper(req.user)
870
+ };
871
+ }
872
+ /** `admin.hidden`: hide unless the login is Developer. */
873
+ function hideUnlessDeveloper({ user }) {
874
+ return !isDeveloper(user);
875
+ }
876
+ //#endregion
877
+ //#region src/features/access.ts
878
+ function isAccessArgs(value) {
879
+ return typeof value === "object" && value !== null && "req" in value;
880
+ }
881
+ function grantedOnReleased(user, slug, row, matrix) {
882
+ if (!row.released) return false;
883
+ return hasGrant(user, slug, matrix);
884
+ }
885
+ /**
886
+ * True when the feature is released and a held role is granted it.
887
+ * Developer is always allowed. An empty Global falls back to catalogue
888
+ * defaults. A group slug (`pages`, `theme`) is true when any child is.
889
+ */
890
+ function hasFeature(user, slug, features = null, matrix = DEFAULT_ROLE_MATRIX) {
891
+ if (isDeveloper(user)) return true;
892
+ if (!user) return false;
893
+ const rows = features && features.length > 0 ? features : defaultFeaturesFieldValue();
894
+ const leaf = rows.find((entry) => entry.slug === slug);
895
+ if (leaf) return grantedOnReleased(user, slug, leaf, matrix);
896
+ if (!isGroupOnlySlug(slug)) return false;
897
+ return rows.some((entry) => entry.group === slug && grantedOnReleased(user, entry.slug, entry, matrix));
898
+ }
899
+ /**
900
+ * Reads the saved Features Global. `null` means empty or unreadable — callers
901
+ * fall back to catalogue defaults. Always override-access.
902
+ */
903
+ async function getFeaturesMatrix(req = {}) {
904
+ const findGlobal = req.payload?.findGlobal;
905
+ if (typeof findGlobal !== "function") return null;
906
+ try {
907
+ const doc = await findGlobal({
908
+ slug: FEATURES_SLUG,
909
+ overrideAccess: true,
910
+ req
911
+ });
912
+ const parsed = parseFeaturesMatrix(doc && typeof doc === "object" && "features" in doc ? doc.features : void 0);
913
+ return parsed.ok ? parsed.rows : null;
914
+ } catch {
915
+ return null;
916
+ }
917
+ }
918
+ /**
919
+ * Access / nav helper for one catalogue slug. Sync against a passed grid;
920
+ * async when given `req` so it can read both Globals.
921
+ */
922
+ function canUseFeature(slug) {
923
+ function predicate(userOrArgs, features, roles) {
924
+ if (isAccessArgs(userOrArgs)) {
925
+ const user = userOrArgs.req.user;
926
+ if (features !== void 0) return hasFeature(user, slug, features, roles ?? DEFAULT_ROLE_MATRIX);
927
+ return Promise.all([getFeaturesMatrix(userOrArgs.req), getRolesMatrix(userOrArgs.req)]).then(([grid, matrix]) => hasFeature(user, slug, grid, matrix));
928
+ }
929
+ return hasFeature(userOrArgs, slug, features ?? null, roles ?? DEFAULT_ROLE_MATRIX);
930
+ }
931
+ return predicate;
932
+ }
933
+ /**
934
+ * Feature slugs (and group slugs) this login may use. Written onto the
935
+ * user at afterRead so `admin.hidden({ user })` can follow the saved
936
+ * Features and Roles Globals without a `req`.
937
+ */
938
+ async function allowedFeaturesForUser(user, req = {}) {
939
+ const [features, roles] = await Promise.all([getFeaturesMatrix(req), getRolesMatrix(req)]);
940
+ return [...(features && features.length > 0 ? features : defaultFeaturesFieldValue()).map((row) => row.slug), ...FEATURE_GROUPS].filter((slug) => hasFeature(user, slug, features, roles));
941
+ }
942
+ function userHasAllowedFeature(user, slug) {
943
+ if (isDeveloper(user)) return true;
944
+ const allowed = user?.allowedFeatures;
945
+ if (!Array.isArray(allowed)) return false;
946
+ return allowed.includes(slug);
947
+ }
948
+ /** `admin.hidden`: hide when the login cannot use the feature. */
949
+ function hideUnlessFeature(slug) {
950
+ return (args) => {
951
+ const user = args.user ?? null;
952
+ if (isDeveloper(user)) return false;
953
+ if (user && Array.isArray(user.allowedFeatures)) return !userHasAllowedFeature(user, slug);
954
+ if (args.req) {
955
+ const result = canUseFeature(slug)({ req: {
956
+ ...args.req,
957
+ user
958
+ } });
959
+ if (result instanceof Promise) return result.then((ok) => !ok);
960
+ return !result;
961
+ }
962
+ return !canUseFeature(slug)(user);
963
+ };
375
964
  }
376
965
  //#endregion
377
966
  //#region src/theme/global.ts
@@ -671,40 +1260,16 @@ function identityFields(collection) {
671
1260
  name: "logoMark",
672
1261
  type: "upload",
673
1262
  relationTo: collection,
674
- label: "Mobile menu logo",
675
- admin: { description: "Optional. Used when the full logo is too wide for the mobile menu — typically the mark." }
676
- }
677
- ];
678
- }
679
- function pageHooks(child) {
680
- return {
681
- beforeChange: [async ({ data, req }) => {
682
- if (req?.payload) await persistThemeChild(req.payload, data, child);
683
- return data;
684
- }],
685
- afterRead: [async ({ doc, req }) => {
686
- if (hasThemeSlice(doc, child)) return doc;
687
- const theme = await req?.payload?.findGlobal({
688
- slug: THEME_SLUG,
689
- draft: false,
690
- depth: child === "identity" ? 1 : 0,
691
- overrideAccess: true
692
- });
693
- if (!theme) return doc;
694
- return {
695
- ...doc,
696
- ...sliceFromTheme(theme, child)
697
- };
698
- }]
699
- };
1263
+ label: "Mobile menu logo",
1264
+ admin: { description: "Optional. Used when the full logo is too wide for the mobile menu — typically the mark." }
1265
+ }
1266
+ ];
700
1267
  }
701
1268
  /**
702
- * Standard Payload Globals in an `admin.group: "Theme"` nav section —
703
- * the same pattern as Content and Settings. Each page is its own Global
704
- * (Save, fields, access). A hidden `theme` store is the published read
705
- * model `getPublishedTheme` already knows. Locking is off: these are
706
- * settings forms, not collaborative documents. No draft mode, no preview
707
- * pane.
1269
+ * One Theme Global. Colors, Typography, Appearance, and Identity are
1270
+ * tabs on that document. Save writes the whole form Payload hydrates
1271
+ * every tab from the live row, so an untouched tab does not revert.
1272
+ * Locking is off. No draft mode, no preview pane.
708
1273
  */
709
1274
  function createTheme(options) {
710
1275
  const access = requireAccess$1(options);
@@ -719,58 +1284,49 @@ function createTheme(options) {
719
1284
  const typeFields = typographyFields(seed, fonts, fontsBaseUrl);
720
1285
  const lookFields = appearanceFields(seed);
721
1286
  const markFields = logo ? identityFields(logo.collection) : [];
722
- const store = {
1287
+ return [{
723
1288
  slug: THEME_SLUG,
724
1289
  label: "Theme",
725
1290
  lockDocuments: false,
726
1291
  admin: {
727
- hidden: true,
1292
+ group: "Theme",
1293
+ hidden: hideUnlessFeature("theme"),
728
1294
  hideAPIURL: true,
729
- custom: adminCustom
1295
+ custom: adminCustom,
1296
+ components: { elements: { beforeDocumentControls: [THEME_DOCUMENT_CONTROLS] } }
730
1297
  },
731
1298
  access: {
732
1299
  read: access.read,
733
1300
  update: access.update
734
1301
  },
735
- fields: [
736
- ...colorFields,
737
- ...typeFields,
738
- ...lookFields,
739
- ...markFields
740
- ],
1302
+ fields: [{
1303
+ type: "tabs",
1304
+ tabs: [
1305
+ {
1306
+ label: "Colors",
1307
+ fields: colorFields
1308
+ },
1309
+ {
1310
+ label: "Typography",
1311
+ fields: typeFields
1312
+ },
1313
+ {
1314
+ label: "Appearance",
1315
+ fields: lookFields
1316
+ },
1317
+ ...logo ? [{
1318
+ label: "Identity",
1319
+ fields: markFields
1320
+ }] : []
1321
+ ]
1322
+ }],
741
1323
  endpoints: colorUsageEndpoints(access, colorUsages),
742
1324
  hooks: { afterChange: [async ({ doc }) => {
743
1325
  if (!onPublish) return;
744
1326
  const themeDoc = doc;
745
1327
  await onPublish(themeConfigFromDoc(themeDoc, seed), themeDoc);
746
1328
  }] }
747
- };
748
- function page(slug, label, child, fields, preview) {
749
- return {
750
- slug,
751
- label,
752
- lockDocuments: false,
753
- admin: {
754
- group: "Theme",
755
- hideAPIURL: true,
756
- custom: adminCustom,
757
- components: preview ? { elements: { beforeDocumentControls: [THEME_DOCUMENT_CONTROLS] } } : void 0
758
- },
759
- access: {
760
- read: access.read,
761
- update: access.update
762
- },
763
- fields,
764
- hooks: pageHooks(child)
765
- };
766
- }
767
- return [
768
- store,
769
- page(THEME_COLORS_SLUG, "Colors", "colors", colorFields, true),
770
- page(THEME_TYPOGRAPHY_SLUG, "Typography", "typography", typeFields, true),
771
- page(THEME_APPEARANCE_SLUG, "Appearance", "appearance", lookFields, true),
772
- ...logo ? [page(THEME_IDENTITY_SLUG, "Identity", "identity", markFields, false)] : []
773
- ];
1329
+ }];
774
1330
  }
775
1331
  //#endregion
776
1332
  //#region src/brand-assets/sanitize.ts
@@ -838,6 +1394,7 @@ function createBrandAssets(options) {
838
1394
  admin: {
839
1395
  group: "Settings",
840
1396
  useAsTitle: "label",
1397
+ hidden: hideUnlessFeature("brand-assets"),
841
1398
  description: "Logo, favicon, and mobile-menu mark. SVG preferred; PNG and ICO allowed."
842
1399
  },
843
1400
  upload: {
@@ -919,288 +1476,73 @@ const THEME_PREVIEW_BREAKPOINTS = [
919
1476
  }
920
1477
  ];
921
1478
  //#endregion
922
- //#region src/roles/types.ts
923
- /**
924
- * The Roles Global a site's generated types will describe. Optional and
925
- * nullable, no index signature: a generated Global is assignable to this,
926
- * never the reverse.
927
- */
928
- const ROLES = [
929
- "developer",
930
- "admin",
931
- "designer",
932
- "author"
933
- ];
934
- const ROLE_LABELS = {
935
- developer: "Developer",
936
- admin: "Admin",
937
- designer: "Designer",
938
- author: "Author"
939
- };
940
- const CAPABILITIES = [
941
- "content",
942
- "brand",
943
- "publish",
944
- "users"
945
- ];
946
- function isRole(value) {
947
- return typeof value === "string" && ROLES.includes(value);
948
- }
949
- /** Lowercase slug: `editor`, `site-editor`. Empty and punctuation are out. */
950
- function isRoleSlug(value) {
951
- return typeof value === "string" && /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(value);
952
- }
953
- function roleLabel(role, label) {
954
- const trimmed = typeof label === "string" ? label.trim() : "";
955
- if (trimmed) return trimmed;
956
- return isRole(role) ? ROLE_LABELS[role] : role;
957
- }
958
- //#endregion
959
- //#region src/roles/matrix.ts
960
- const ROLES_SLUG = "roles";
961
- const ROLES_GLOBAL_DESCRIPTION = "Who may do what. Drag to change rank.";
962
- const ROLES_FIELD_DESCRIPTION = "Drag to change rank. Ticks are Content, Brand, Publish, and Users.";
1479
+ //#region src/theme/publish.ts
963
1480
  /**
964
- * Default rank and ticks. Brand is Designer only. The API tab is not a
965
- * column it is locked to Developer in `isDeveloper`.
1481
+ * Publishing a child writes that slice only. Other responsibilities stay
1482
+ * as they already are on the stored document. Unsaved form state on a
1483
+ * different child never goes live. A first save (no stored row) still
1484
+ * writes only the named child; `getPublishedTheme` fills the rest from
1485
+ * the seed.
966
1486
  */
967
- const DEFAULT_ROLE_MATRIX = [
968
- {
969
- role: "developer",
970
- content: true,
971
- brand: true,
972
- publish: true,
973
- users: true
974
- },
975
- {
976
- role: "admin",
977
- content: true,
978
- brand: false,
979
- publish: true,
980
- users: true
981
- },
982
- {
983
- role: "designer",
984
- content: false,
985
- brand: true,
986
- publish: false,
987
- users: false
988
- },
989
- {
990
- role: "author",
991
- content: true,
992
- brand: false,
993
- publish: false,
994
- users: false
995
- }
996
- ];
997
- const DEVELOPER_DESCRIPTION = "Everything Admin can do, plus the document API tab.";
998
- const LAST_USERS_TICK_MESSAGE = "Keep Users ticked on at least one of Admin or Developer.";
999
- const MISSING_SEED_ROLES_MESSAGE = "Roles must include Developer, Admin, Designer, and Author.";
1000
- const DUPLICATE_ROLE_SLUG_MESSAGE = "Each role needs a unique slug.";
1001
- const CAPABILITY_LABELS = {
1002
- content: "Content",
1003
- brand: "Brand",
1004
- publish: "Publish",
1005
- users: "Users"
1006
- };
1007
- function seedRoleRows(extras = []) {
1008
- return [...DEFAULT_ROLE_MATRIX.map((row) => ({
1009
- ...row,
1010
- label: isRole(row.role) ? ROLE_LABELS[row.role] : row.role
1011
- })), ...extras.map((extra) => ({ ...extra }))];
1012
- }
1013
- function defaultRolesFieldValue(extras = []) {
1014
- return seedRoleRows(extras).map((row) => ({
1015
- id: row.role,
1016
- ...row
1017
- }));
1018
- }
1019
- function readRoleRow(item) {
1020
- const role = "role" in item ? item.role : void 0;
1021
- if (typeof role !== "string" || role.trim() === "") return { error: DUPLICATE_ROLE_SLUG_MESSAGE };
1022
- if (!isRoleSlug(role)) return { error: DUPLICATE_ROLE_SLUG_MESSAGE };
1023
- return {
1024
- id: role,
1025
- role,
1026
- label: roleLabel(role, "label" in item && typeof item.label === "string" ? item.label : void 0),
1027
- content: Boolean("content" in item && item.content),
1028
- brand: Boolean("brand" in item && item.brand),
1029
- publish: Boolean("publish" in item && item.publish),
1030
- users: Boolean("users" in item && item.users)
1487
+ function publishThemeChild(stored, incoming, child) {
1488
+ if (child === "colors") return {
1489
+ ...stored,
1490
+ colors: incoming.colors
1031
1491
  };
1032
- }
1033
- function parseRolesMatrix(value) {
1034
- if (!Array.isArray(value)) return {
1035
- ok: false,
1036
- message: MISSING_SEED_ROLES_MESSAGE
1492
+ if (child === "typography") return {
1493
+ ...stored,
1494
+ typography: incoming.typography
1037
1495
  };
1038
- const rows = [];
1039
- const seen = /* @__PURE__ */ new Set();
1040
- for (const item of value) {
1041
- if (!item || typeof item !== "object") continue;
1042
- const parsed = readRoleRow(item);
1043
- if ("error" in parsed) return {
1044
- ok: false,
1045
- message: parsed.error
1046
- };
1047
- if (seen.has(parsed.role)) return {
1048
- ok: false,
1049
- message: DUPLICATE_ROLE_SLUG_MESSAGE
1050
- };
1051
- seen.add(parsed.role);
1052
- rows.push(parsed);
1053
- }
1054
- if (ROLES.some((role) => !seen.has(role))) return {
1055
- ok: false,
1056
- message: MISSING_SEED_ROLES_MESSAGE
1496
+ if (child === "appearance") return {
1497
+ ...stored,
1498
+ appearance: incoming.appearance
1057
1499
  };
1058
1500
  return {
1059
- ok: true,
1060
- rows
1501
+ ...stored,
1502
+ logo: incoming.logo,
1503
+ favicon: incoming.favicon,
1504
+ logoMark: incoming.logoMark
1061
1505
  };
1062
1506
  }
1063
- function hasPrivilegedUsersTick(rows) {
1064
- return rows.some((row) => (row.role === "admin" || row.role === "developer") && row.users);
1065
- }
1066
- function validateRolesMatrix(value) {
1067
- const parsed = parseRolesMatrix(value);
1068
- if (!parsed.ok) return parsed.message;
1069
- if (!hasPrivilegedUsersTick(parsed.rows)) return LAST_USERS_TICK_MESSAGE;
1070
- return true;
1071
- }
1072
- /**
1073
- * Developer is exclusive. Admin does not swallow Designer: both store.
1074
- * Unknown slugs (dropped catalogue keys such as `approver`) fall away unless
1075
- * they appear on the matrix.
1076
- */
1077
- function normalizeStoredRoles(value, matrix = DEFAULT_ROLE_MATRIX) {
1078
- if (!Array.isArray(value)) return [];
1079
- const allowed = new Set(matrix.map((row) => row.role));
1080
- const roles = [...new Set(value.filter((entry) => typeof entry === "string" && allowed.has(entry)))];
1081
- if (roles.includes("developer")) return ["developer"];
1082
- return roles;
1083
- }
1084
- function roleSelectOptions(matrix = DEFAULT_ROLE_MATRIX) {
1085
- return matrix.map((row) => ({
1086
- label: roleLabel(row.role, row.label),
1087
- value: row.role
1088
- }));
1089
- }
1090
- /** Capability copy only. Developer names the API tab; nothing about MCP or seed. */
1091
- function roleDescription(role, matrix = DEFAULT_ROLE_MATRIX) {
1092
- if (role === "developer") return DEVELOPER_DESCRIPTION;
1093
- const row = matrix.find((entry) => entry.role === role);
1094
- if (!row) return "No capabilities";
1095
- return CAPABILITIES.filter((capability) => row[capability]).map((capability) => CAPABILITY_LABELS[capability]).join(", ") || "No capabilities";
1096
- }
1097
- /**
1098
- * Seed ticks and package labels come back; extra rows stay as they are.
1099
- */
1100
- function resetRolesMatrix(current) {
1101
- const extras = [];
1102
- if (Array.isArray(current)) for (const item of current) {
1103
- if (!item || typeof item !== "object") continue;
1104
- const parsed = readRoleRow(item);
1105
- if ("error" in parsed || isRole(parsed.role)) continue;
1106
- extras.push(parsed);
1107
- }
1108
- return [...defaultRolesFieldValue(), ...extras];
1109
- }
1110
- //#endregion
1111
- //#region src/roles/access.ts
1112
- function isAccessArgs$1(value) {
1113
- return typeof value === "object" && value !== null && "req" in value;
1114
- }
1115
- function storedRoles(user) {
1116
- return Array.isArray(user?.roles) ? user.roles : [];
1117
- }
1118
- function hasRole(user, role) {
1119
- return storedRoles(user).includes(role);
1120
- }
1121
- /** Not a tick. True only when `developer` is stored on the row. */
1122
- function isDeveloper(user) {
1123
- return hasRole(user, "developer");
1124
- }
1125
- function hasCapability(user, capability, matrix = DEFAULT_ROLE_MATRIX) {
1126
- return storedRoles(user).some((role) => {
1127
- return matrix.find((entry) => entry.role === role)?.[capability] === true;
1128
- });
1129
- }
1130
1507
  /**
1131
- * Reads the saved Roles Global, falling back to the seed when the row is
1132
- * empty, missing, or unreadable. Always override-access so a Designer
1133
- * evaluating Theme does not have to read Settings → Roles.
1134
- */
1135
- async function getRolesMatrix(req = {}, extras = []) {
1136
- const fallback = seedRoleRows(extras);
1137
- const findGlobal = req.payload?.findGlobal;
1138
- if (typeof findGlobal !== "function") return fallback;
1139
- try {
1140
- const doc = await findGlobal({
1141
- slug: ROLES_SLUG,
1142
- overrideAccess: true,
1143
- req
1144
- });
1145
- const parsed = parseRolesMatrix(doc && typeof doc === "object" && "roles" in doc ? doc.roles : void 0);
1146
- return parsed.ok ? parsed.rows : fallback;
1147
- } catch {
1148
- return fallback;
1149
- }
1150
- }
1151
- function capabilityPredicate(capability) {
1152
- function predicate(userOrArgs, matrix) {
1153
- if (isAccessArgs$1(userOrArgs)) {
1154
- const user = userOrArgs.req.user;
1155
- if (matrix) return hasCapability(user, capability, matrix);
1156
- return getRolesMatrix(userOrArgs.req).then((rows) => hasCapability(user, capability, rows));
1157
- }
1158
- return hasCapability(userOrArgs, capability, matrix ?? DEFAULT_ROLE_MATRIX);
1159
- }
1160
- return predicate;
1161
- }
1162
- const canManageContent = capabilityPredicate("content");
1163
- const canManageBrand = capabilityPredicate("brand");
1164
- const canPublish = capabilityPredicate("publish");
1165
- /** Users tick on any held role. */
1166
- const isAdmin = capabilityPredicate("users");
1167
- /** This role id currently has the Users tick. */
1168
- function isPrivilegedRole(role, matrix = DEFAULT_ROLE_MATRIX) {
1169
- return typeof role === "string" && matrix.some((row) => row.role === role && row.users);
1170
- }
1171
- function isAuthenticated(userOrArgs) {
1172
- if (isAccessArgs$1(userOrArgs)) return Boolean(userOrArgs.req.user);
1173
- return Boolean(userOrArgs);
1174
- }
1175
- const isAdminOrSelf = async ({ req }) => {
1176
- if (!req.user) return false;
1177
- if (await isAdmin({ req })) return true;
1178
- return { id: { equals: req.user.id } };
1179
- };
1180
- const authenticatedOrPublished = ({ req: { user } }) => {
1181
- if (isAuthenticated(user)) return true;
1182
- return { _status: { equals: "published" } };
1183
- };
1184
- /** API tab condition: Developer only, not the Users tick. */
1185
- function isDeveloperTab({ req }) {
1186
- return isDeveloper(req.user);
1508
+ * Save on a Theme page writes that slice onto the hidden `theme` row.
1509
+ * Other children stay as stored.
1510
+ */
1511
+ async function persistThemeChild(payload, incoming, child) {
1512
+ const next = publishThemeChild(await payload.findGlobal({
1513
+ slug: "theme",
1514
+ draft: false,
1515
+ overrideAccess: true
1516
+ }) ?? {}, incoming, child);
1517
+ await payload.updateGlobal({
1518
+ slug: THEME_SLUG,
1519
+ data: next,
1520
+ draft: false
1521
+ });
1522
+ return next;
1187
1523
  }
1188
1524
  //#endregion
1189
1525
  //#region src/roles/fields.ts
1190
1526
  const ROLES_MATRIX_FIELD = "@bison-lab/payload-core/admin#RolesMatrixField";
1191
1527
  const ROLES_ROW_LABEL = "@bison-lab/payload-core/admin#RolesRowLabel";
1192
1528
  const ROLE_SLUG_FIELD = "@bison-lab/payload-core/admin#RoleSlugField";
1529
+ const ROLE_NAME_FIELD = "@bison-lab/payload-core/admin#RoleNameField";
1193
1530
  const ROLES_FIELD = "@bison-lab/payload-core/admin#RolesField";
1531
+ const ROLES_GRANTS_FIELD = "@bison-lab/payload-core/admin#RolesGrantsField";
1194
1532
  //#endregion
1195
1533
  //#region src/roles/global.ts
1196
- const roleSlugValidate = (value, options) => {
1197
- if (options.operation === "update" && isRole(options.previousValue)) return value === options.previousValue || "Each role needs a unique slug.";
1198
- if (typeof value !== "string" || value === "") return DUPLICATE_ROLE_SLUG_MESSAGE;
1534
+ const roleSlugValidate = (value) => {
1535
+ if (typeof value !== "string" || value === "") return true;
1199
1536
  return isRoleSlug(value) || "Each role needs a unique slug.";
1200
1537
  };
1538
+ const roleNameValidate = (value) => {
1539
+ if (typeof value !== "string" || value.trim() === "") return true;
1540
+ return isRoleName(value) || "Use letters and spaces only.";
1541
+ };
1201
1542
  /**
1202
- * Settings → Roles. Rank is the array order (Payload's drag handle). Ticks
1203
- * are Content, Brand, Publish, Users. The API tab is not a column.
1543
+ * Settings → Roles. Rank is the array order (Payload's drag handle).
1544
+ * Ticks are released catalogue rows. Developer is implicit and shown
1545
+ * only to a Developer, with every catalogue tick locked on.
1204
1546
  */
1205
1547
  function createRoles({ extras = [] } = {}) {
1206
1548
  const rolesField = {
@@ -1215,6 +1557,7 @@ function createRoles({ extras = [] } = {}) {
1215
1557
  required: true,
1216
1558
  defaultValue: defaultRolesFieldValue(extras),
1217
1559
  validate: validateRolesMatrix,
1560
+ hooks: { beforeChange: [({ value }) => applyRoleSlugsFromNames(value)] },
1218
1561
  admin: {
1219
1562
  components: {
1220
1563
  Field: ROLES_MATRIX_FIELD,
@@ -1228,36 +1571,23 @@ function createRoles({ extras = [] } = {}) {
1228
1571
  name: "role",
1229
1572
  type: "text",
1230
1573
  label: "Slug",
1231
- required: true,
1232
1574
  validate: roleSlugValidate,
1233
- hooks: { beforeChange: [({ value, previousValue }) => isRole(previousValue) ? previousValue : value] },
1234
- admin: { components: { Field: ROLE_SLUG_FIELD } }
1575
+ admin: { hidden: true }
1235
1576
  },
1236
1577
  {
1237
1578
  name: "label",
1238
1579
  type: "text",
1239
1580
  label: "Name",
1240
- required: true
1241
- },
1242
- {
1243
- name: "content",
1244
- type: "checkbox",
1245
- label: "Content"
1246
- },
1247
- {
1248
- name: "brand",
1249
- type: "checkbox",
1250
- label: "Brand"
1251
- },
1252
- {
1253
- name: "publish",
1254
- type: "checkbox",
1255
- label: "Publish"
1581
+ required: true,
1582
+ validate: roleNameValidate,
1583
+ admin: { components: { Field: ROLE_NAME_FIELD } }
1256
1584
  },
1257
1585
  {
1258
- name: "users",
1259
- type: "checkbox",
1260
- label: "Users"
1586
+ name: "grants",
1587
+ type: "json",
1588
+ label: "Grants",
1589
+ defaultValue: [],
1590
+ admin: { components: { Field: ROLES_GRANTS_FIELD } }
1261
1591
  }
1262
1592
  ]
1263
1593
  };
@@ -1266,11 +1596,11 @@ function createRoles({ extras = [] } = {}) {
1266
1596
  label: "Roles",
1267
1597
  admin: {
1268
1598
  group: "Settings",
1269
- hidden: ({ user }) => !isAdmin(user),
1599
+ hidden: hideUnlessFeature("roles"),
1270
1600
  description: ROLES_GLOBAL_DESCRIPTION
1271
1601
  },
1272
1602
  access: {
1273
- read: (args) => isAdmin(args),
1603
+ read: (args) => canUseFeature("roles")(args),
1274
1604
  update: (args) => isAdmin(args)
1275
1605
  },
1276
1606
  fields: [rolesField]
@@ -1293,237 +1623,75 @@ async function seedRoles(payload, extras = []) {
1293
1623
  //#region src/features/fields.ts
1294
1624
  const FEATURES_MATRIX_FIELD = "@bison-lab/payload-core/admin#FeaturesMatrixField";
1295
1625
  //#endregion
1296
- //#region src/features/types.ts
1297
- const FEATURES_SLUG = "features";
1298
- const PACKAGE_FEATURE_SLUGS = [
1299
- "pages",
1300
- "media",
1301
- "theme",
1302
- "brand-assets",
1303
- "users",
1304
- "roles",
1305
- "features"
1306
- ];
1307
- /**
1308
- * Package catalogue. Locked rows cannot be turned off. Empty Global falls
1309
- * back to the named capability (Content → Pages/Media, Brand → Theme /
1310
- * Brand assets, Users → Users/Roles/Features).
1311
- */
1312
- const PACKAGE_FEATURES = [
1313
- {
1314
- slug: "pages",
1315
- label: "Pages",
1316
- fallback: "content",
1317
- locked: false
1318
- },
1319
- {
1320
- slug: "media",
1321
- label: "Media",
1322
- fallback: "content",
1323
- locked: false
1324
- },
1325
- {
1326
- slug: "theme",
1327
- label: "Theme",
1328
- fallback: "brand",
1329
- locked: false
1330
- },
1331
- {
1332
- slug: "brand-assets",
1333
- label: "Brand assets",
1334
- fallback: "brand",
1335
- locked: false
1336
- },
1337
- {
1338
- slug: "users",
1339
- label: "Users",
1340
- fallback: "users",
1341
- locked: true
1342
- },
1343
- {
1344
- slug: "roles",
1345
- label: "Roles",
1346
- fallback: "users",
1347
- locked: true
1348
- },
1349
- {
1350
- slug: "features",
1351
- label: "Features",
1352
- fallback: "users",
1353
- locked: true
1354
- }
1355
- ];
1356
- function isPackageFeatureSlug(value) {
1357
- return typeof value === "string" && PACKAGE_FEATURE_SLUGS.includes(value);
1358
- }
1359
- function isFeatureSlug(value) {
1360
- return typeof value === "string" && /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(value);
1361
- }
1362
- function isLockedFeature(slug, locked) {
1363
- if (locked === true) return true;
1364
- return PACKAGE_FEATURES.find((feature) => feature.slug === slug)?.locked === true;
1365
- }
1366
- //#endregion
1367
- //#region src/features/matrix.ts
1368
- const MISSING_PACKAGE_FEATURES_MESSAGE = "Features must include Pages, Media, Theme, Brand assets, Users, Roles, and Features.";
1369
- const LOCKED_FEATURE_MESSAGE = "Users, Roles, and Features cannot be turned off. Developer stays allowed on every row.";
1370
- function featureCatalogue(extras = []) {
1371
- return [...PACKAGE_FEATURES.map((feature) => ({ ...feature })), ...extras.map((extra) => ({
1372
- slug: extra.slug,
1373
- label: extra.label,
1374
- fallback: null,
1375
- locked: false
1376
- }))];
1377
- }
1378
- function rolesForFeature(feature, matrix) {
1379
- const allowed = new Set(["developer"]);
1380
- if (feature.locked) {
1381
- for (const row of matrix) allowed.add(row.role);
1382
- return [...allowed];
1383
- }
1384
- if (feature.fallback) {
1385
- for (const row of matrix) if (row[feature.fallback]) allowed.add(row.role);
1386
- }
1387
- return [...allowed];
1388
- }
1389
- function defaultFeaturesFieldValue(extras = [], matrix = DEFAULT_ROLE_MATRIX) {
1390
- return featureCatalogue(extras).map((feature) => ({
1391
- id: feature.slug,
1392
- slug: feature.slug,
1393
- label: feature.label,
1394
- locked: feature.locked,
1395
- roles: rolesForFeature(feature, matrix)
1396
- }));
1397
- }
1398
- function parseFeaturesMatrix(value) {
1399
- if (!Array.isArray(value) || value.length === 0) return {
1400
- ok: false,
1401
- message: MISSING_PACKAGE_FEATURES_MESSAGE
1402
- };
1403
- const rows = [];
1404
- const seen = /* @__PURE__ */ new Set();
1405
- for (const item of value) {
1406
- if (!item || typeof item !== "object") continue;
1407
- const slug = "slug" in item ? item.slug : void 0;
1408
- if (!isFeatureSlug(slug) || seen.has(slug)) continue;
1409
- seen.add(slug);
1410
- const rawRoles = "roles" in item && Array.isArray(item.roles) ? item.roles : [];
1411
- const roles = [];
1412
- for (const role of rawRoles) if (typeof role === "string" && !roles.includes(role)) roles.push(role);
1413
- const label = "label" in item && typeof item.label === "string" ? item.label : slug;
1414
- rows.push({
1415
- id: slug,
1416
- slug,
1417
- label,
1418
- locked: isLockedFeature(slug, "locked" in item ? Boolean(item.locked) : void 0),
1419
- roles
1420
- });
1421
- }
1422
- if (PACKAGE_FEATURES.some((feature) => !seen.has(feature.slug))) return {
1423
- ok: false,
1424
- message: MISSING_PACKAGE_FEATURES_MESSAGE
1425
- };
1426
- return {
1427
- ok: true,
1428
- rows
1429
- };
1430
- }
1431
- function enforceFeatureLocks(rows, matrix = DEFAULT_ROLE_MATRIX) {
1432
- const roleSlugs = matrix.map((row) => row.role);
1433
- return rows.map((row) => {
1434
- const roles = new Set(row.roles ?? []);
1435
- roles.add("developer");
1436
- if (isLockedFeature(row.slug, row.locked)) for (const role of roleSlugs) roles.add(role);
1437
- return {
1438
- ...row,
1439
- roles: [...roles]
1440
- };
1441
- });
1442
- }
1443
- function validateFeaturesMatrix(value, extras = [], matrix = DEFAULT_ROLE_MATRIX) {
1444
- const parsed = parseFeaturesMatrix(value);
1445
- if (!parsed.ok) return parsed.message;
1446
- const allowed = new Set(featureCatalogue(extras).map((feature) => feature.slug));
1447
- if (parsed.rows.some((row) => !allowed.has(row.slug))) return MISSING_PACKAGE_FEATURES_MESSAGE;
1448
- for (const row of parsed.rows) {
1449
- if (!row.roles?.includes("developer")) return LOCKED_FEATURE_MESSAGE;
1450
- if (isLockedFeature(row.slug, row.locked) && matrix.some((role) => !row.roles?.includes(role.role))) return LOCKED_FEATURE_MESSAGE;
1451
- }
1452
- return true;
1453
- }
1454
- //#endregion
1455
1626
  //#region src/features/global.ts
1456
1627
  /**
1457
- * Settings → Features. Rows are the catalogue (code). Columns are the
1458
- * Roles Global. Locked rows and Developer cannot be turned off.
1628
+ * Settings → Features. One release switch per catalogue row. Off hides
1629
+ * the tick on Roles; Developer still has the feature. This screen is
1630
+ * not itself a row.
1459
1631
  */
1460
1632
  function createFeatures({ extras = [] } = {}) {
1461
1633
  const catalogue = featureCatalogue(extras);
1462
- return {
1463
- slug: FEATURES_SLUG,
1634
+ const featuresField = {
1635
+ name: "features",
1636
+ type: "array",
1464
1637
  label: "Features",
1465
- admin: {
1466
- group: "Settings",
1467
- hidden: ({ user }) => !isAdmin(user),
1468
- description: "Who may use which collections and globals."
1638
+ labels: {
1639
+ singular: "Feature",
1640
+ plural: "Features"
1469
1641
  },
1470
- access: {
1471
- read: (args) => isAdmin(args),
1472
- update: (args) => isAdmin(args)
1642
+ minRows: catalogue.length,
1643
+ maxRows: catalogue.length,
1644
+ required: true,
1645
+ defaultValue: defaultFeaturesFieldValue(extras),
1646
+ validate: (value) => validateFeaturesMatrix(value, extras),
1647
+ hooks: { beforeChange: [({ value }) => {
1648
+ if (!Array.isArray(value)) return value;
1649
+ return stampFeatureCatalogue(value, extras);
1650
+ }] },
1651
+ admin: {
1652
+ components: { Field: FEATURES_MATRIX_FIELD },
1653
+ description: "Release a feature so it can be assigned on Roles. Off keeps it Developer-only.",
1654
+ initCollapsed: false
1473
1655
  },
1474
- fields: [{
1475
- name: "features",
1476
- type: "array",
1477
- label: "Features",
1478
- labels: {
1479
- singular: "Feature",
1480
- plural: "Features"
1656
+ fields: [
1657
+ {
1658
+ name: "slug",
1659
+ type: "text",
1660
+ label: "Slug",
1661
+ required: true,
1662
+ admin: { readOnly: true }
1481
1663
  },
1482
- minRows: catalogue.length,
1483
- maxRows: catalogue.length,
1484
- required: true,
1485
- defaultValue: defaultFeaturesFieldValue(extras),
1486
- validate: async (value, { req }) => {
1487
- return validateFeaturesMatrix(value, extras, await getRolesMatrix(req));
1664
+ {
1665
+ name: "label",
1666
+ type: "text",
1667
+ label: "Name",
1668
+ required: true,
1669
+ admin: { readOnly: true }
1488
1670
  },
1489
- hooks: { beforeChange: [async ({ value, req }) => {
1490
- if (!Array.isArray(value)) return value;
1491
- return enforceFeatureLocks(value, await getRolesMatrix(req));
1492
- }] },
1493
- admin: {
1494
- components: { Field: FEATURES_MATRIX_FIELD },
1495
- description: "Turn a feature on or off per role. Users, Roles, and Features stay on. Developer is always allowed.",
1496
- initCollapsed: false
1671
+ {
1672
+ name: "group",
1673
+ type: "text",
1674
+ label: "Group",
1675
+ admin: { hidden: true }
1497
1676
  },
1498
- fields: [
1499
- {
1500
- name: "slug",
1501
- type: "text",
1502
- label: "Slug",
1503
- required: true,
1504
- admin: { readOnly: true }
1505
- },
1506
- {
1507
- name: "label",
1508
- type: "text",
1509
- label: "Name",
1510
- required: true,
1511
- admin: { readOnly: true }
1512
- },
1513
- {
1514
- name: "locked",
1515
- type: "checkbox",
1516
- label: "Locked",
1517
- admin: { hidden: true }
1518
- },
1519
- {
1520
- name: "roles",
1521
- type: "json",
1522
- label: "Roles",
1523
- required: true
1524
- }
1525
- ]
1526
- }]
1677
+ {
1678
+ name: "released",
1679
+ type: "checkbox",
1680
+ label: "Released",
1681
+ admin: { hidden: true }
1682
+ }
1683
+ ]
1684
+ };
1685
+ return {
1686
+ slug: FEATURES_SLUG,
1687
+ label: "Features",
1688
+ admin: {
1689
+ group: "Settings",
1690
+ hidden: hideUnlessDeveloper,
1691
+ description: "Which features other people may be granted on Roles."
1692
+ },
1693
+ access: developerOnlyAccess(),
1694
+ fields: [featuresField]
1527
1695
  };
1528
1696
  }
1529
1697
  //#endregion
@@ -1539,80 +1707,6 @@ async function seedFeatures(payload, extras = []) {
1539
1707
  });
1540
1708
  }
1541
1709
  //#endregion
1542
- //#region src/features/access.ts
1543
- function isAccessArgs(value) {
1544
- return typeof value === "object" && value !== null && "req" in value;
1545
- }
1546
- function fallbackFeature(user, slug, matrix) {
1547
- const pack = PACKAGE_FEATURES.find((feature) => feature.slug === slug);
1548
- if (!pack) return false;
1549
- return hasCapability(user, pack.fallback, matrix);
1550
- }
1551
- /**
1552
- * True when any stored role is on the feature row. An empty grid falls
1553
- * back to the capability map. Developer is always allowed.
1554
- */
1555
- function hasFeature(user, slug, features = null, matrix = DEFAULT_ROLE_MATRIX) {
1556
- if (isDeveloper(user)) return true;
1557
- if (!user) return false;
1558
- if (!features || features.length === 0) return fallbackFeature(user, slug, matrix);
1559
- const row = features.find((entry) => entry.slug === slug);
1560
- if (!row) return false;
1561
- const allowed = row.roles ?? [];
1562
- return storedRoles(user).some((role) => allowed.includes(role));
1563
- }
1564
- /**
1565
- * Reads the saved Features Global. `null` means empty or unreadable — callers
1566
- * fall back to the capability map. Always override-access.
1567
- */
1568
- async function getFeaturesMatrix(req = {}) {
1569
- const findGlobal = req.payload?.findGlobal;
1570
- if (typeof findGlobal !== "function") return null;
1571
- try {
1572
- const doc = await findGlobal({
1573
- slug: FEATURES_SLUG,
1574
- overrideAccess: true,
1575
- req
1576
- });
1577
- const parsed = parseFeaturesMatrix(doc && typeof doc === "object" && "features" in doc ? doc.features : void 0);
1578
- if (!parsed.ok) return null;
1579
- const matrix = await getRolesMatrix(req);
1580
- return enforceFeatureLocks(parsed.rows, matrix);
1581
- } catch {
1582
- return null;
1583
- }
1584
- }
1585
- /**
1586
- * Access / nav helper for one catalogue slug. Sync against a passed grid;
1587
- * async when given `req` so it can read both Globals.
1588
- */
1589
- function canUseFeature(slug) {
1590
- function predicate(userOrArgs, features, roles) {
1591
- if (isAccessArgs(userOrArgs)) {
1592
- const user = userOrArgs.req.user;
1593
- if (features !== void 0) return hasFeature(user, slug, features, roles ?? DEFAULT_ROLE_MATRIX);
1594
- return Promise.all([getFeaturesMatrix(userOrArgs.req), getRolesMatrix(userOrArgs.req)]).then(([grid, matrix]) => hasFeature(user, slug, grid, matrix));
1595
- }
1596
- return hasFeature(userOrArgs, slug, features ?? null, roles ?? DEFAULT_ROLE_MATRIX);
1597
- }
1598
- return predicate;
1599
- }
1600
- /** `admin.hidden`: hide when the login cannot use the feature. */
1601
- function hideUnlessFeature(slug) {
1602
- return (args) => {
1603
- const user = args.user;
1604
- if (args.req) {
1605
- const result = canUseFeature(slug)({ req: {
1606
- ...args.req,
1607
- user
1608
- } });
1609
- if (result instanceof Promise) return result.then((ok) => !ok);
1610
- return !result;
1611
- }
1612
- return !canUseFeature(slug)(user ?? null);
1613
- };
1614
- }
1615
- //#endregion
1616
1710
  //#region src/fields/slug.ts
1617
1711
  /**
1618
1712
  * A stored slug from whatever was typed: lowercased, with any run of
@@ -1694,6 +1788,15 @@ function slugField({ collection, isReserved }) {
1694
1788
  //#endregion
1695
1789
  //#region src/collections/pages.ts
1696
1790
  /**
1791
+ * Content to create or save a draft; Publish to publish or edit a live
1792
+ * page. Without Publish, update is constrained to `_status: draft`.
1793
+ */
1794
+ const pagesUpdate = async (args) => {
1795
+ if (!await Promise.resolve(canUseFeature("content")(args))) return false;
1796
+ if (await Promise.resolve(canPublish(args))) return true;
1797
+ return { _status: { equals: "draft" } };
1798
+ };
1799
+ /**
1697
1800
  * CMS-managed pages: one required hero, then a reorderable body of sections.
1698
1801
  * The CMS owns copy and the order of sections; what a section looks like is
1699
1802
  * code-owned, which is why the blocks are an argument.
@@ -1712,15 +1815,15 @@ function createPages({ heroBlocks, isReservedSlug, layoutBlocks, previewPath, pr
1712
1815
  "_status",
1713
1816
  "updatedAt"
1714
1817
  ],
1715
- hidden: hideUnlessFeature("pages"),
1818
+ hidden: hideUnlessFeature("content"),
1716
1819
  preview: (doc) => previewPath(typeof doc.slug === "string" ? doc.slug : ""),
1717
1820
  ...previewButton ? { components: { edit: { PreviewButton: previewButton } } } : {}
1718
1821
  },
1719
1822
  access: {
1720
1823
  read: authenticatedOrPublished,
1721
1824
  readVersions: isAuthenticated,
1722
- create: canUseFeature("pages"),
1723
- update: canUseFeature("pages"),
1825
+ create: canUseFeature("content"),
1826
+ update: pagesUpdate,
1724
1827
  delete: isAdmin
1725
1828
  },
1726
1829
  versions: {
@@ -1791,7 +1894,7 @@ async function lockLastAdminDecision(req) {
1791
1894
  });
1792
1895
  }
1793
1896
  function privilegedRoles(matrix) {
1794
- return matrix.filter((row) => row.users).map((row) => row.role);
1897
+ return matrix.filter((row) => isPrivilegedRole(row.role, matrix)).map((row) => row.role);
1795
1898
  }
1796
1899
  function holdsPrivilegedRole(roles, matrix) {
1797
1900
  return Array.isArray(roles) && roles.some((role) => isPrivilegedRole(role, matrix));
@@ -1847,13 +1950,16 @@ function createUsers({ secureCookies, rolesField, extras = [] }) {
1847
1950
  "lastName",
1848
1951
  "roles"
1849
1952
  ],
1850
- hidden: ({ user }) => !isAdmin(user)
1953
+ hidden: hideUnlessFeature("users")
1851
1954
  },
1852
1955
  access: {
1853
1956
  create: isAdmin,
1854
1957
  delete: isAdmin,
1855
1958
  unlock: isAdmin,
1856
- read: isAdminOrSelf,
1959
+ read: async (args) => {
1960
+ if (await Promise.resolve(canUseFeature("users")(args))) return true;
1961
+ return isAdminOrSelf(args);
1962
+ },
1857
1963
  update: isAdminOrSelf
1858
1964
  },
1859
1965
  hooks: {
@@ -1876,27 +1982,45 @@ function createUsers({ secureCookies, rolesField, extras = [] }) {
1876
1982
  return arg.result;
1877
1983
  }]
1878
1984
  },
1879
- fields: [{
1880
- type: "row",
1881
- fields: [nameField("firstName"), nameField("lastName")]
1882
- }, {
1883
- name: "roles",
1884
- type: "select",
1885
- hasMany: true,
1886
- required: true,
1887
- defaultValue: ["author"],
1888
- options: roleSelectOptions(fallback),
1889
- admin: {
1890
- components: { Field: rolesField ?? "@bison-lab/payload-core/admin#RolesField" },
1891
- description: "One person can hold several, e.g. Author + Designer."
1985
+ fields: [
1986
+ {
1987
+ type: "row",
1988
+ fields: [nameField("firstName"), nameField("lastName")]
1892
1989
  },
1893
- access: { update: isAdmin },
1894
- validate: rolesValidate(extras),
1895
- hooks: { beforeValidate: [async ({ value, req }) => {
1896
- if (!Array.isArray(value)) return value;
1897
- return normalizeStoredRoles(value, await getRolesMatrix(req, extras));
1898
- }] }
1899
- }]
1990
+ {
1991
+ name: "roles",
1992
+ type: "select",
1993
+ hasMany: true,
1994
+ required: true,
1995
+ defaultValue: ["author"],
1996
+ saveToJWT: true,
1997
+ options: roleSelectOptions(fallback),
1998
+ admin: {
1999
+ components: { Field: rolesField ?? "@bison-lab/payload-core/admin#RolesField" },
2000
+ description: "One person can hold several, e.g. Author + Designer."
2001
+ },
2002
+ access: { update: isAdmin },
2003
+ validate: rolesValidate(extras),
2004
+ hooks: { beforeValidate: [async ({ value, req }) => {
2005
+ if (!Array.isArray(value)) return value;
2006
+ return normalizeStoredRoles(value, await getRolesMatrix(req, extras));
2007
+ }] }
2008
+ },
2009
+ {
2010
+ name: "allowedFeatures",
2011
+ type: "json",
2012
+ admin: {
2013
+ hidden: true,
2014
+ readOnly: true
2015
+ },
2016
+ access: { update: () => false },
2017
+ saveToJWT: true,
2018
+ hooks: { afterRead: [async ({ siblingData, data, req }) => allowedFeaturesForUser({
2019
+ id: siblingData?.id ?? data?.id,
2020
+ roles: siblingData?.roles ?? data?.roles
2021
+ }, req ?? {})] }
2022
+ }
2023
+ ]
1900
2024
  };
1901
2025
  }
1902
2026
  //#endregion
@@ -1972,6 +2096,6 @@ const adminOnlyApiTab = definePlugin({
1972
2096
  })
1973
2097
  });
1974
2098
  //#endregion
1975
- export { BRAND_ASSETS_MIME_TYPES, BRAND_ASSETS_SLUG, CAPABILITIES, DEFAULT_ROLE_MATRIX, DESCRIPTION_LENGTH, DEVELOPER_DESCRIPTION, DUPLICATE_ROLE_SLUG_MESSAGE, FEATURES_MATRIX_FIELD, FEATURES_SLUG, LAST_ADMIN_DELETE_MESSAGE, LAST_ADMIN_DEMOTE_MESSAGE, LAST_USERS_TICK_MESSAGE, LOCKED_FEATURE_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_MATRIX_FIELD, ROLES_ROW_LABEL, ROLES_SLUG, ROLE_LABELS, 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_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, authenticatedOrPublished, canManageBrand, canManageContent, canPublish, canUseFeature, colorTokenField, createBrandAssets, createFeatures, createMedia, createPages, createRoles, createTheme, createUsers, defaultFeaturesFieldValue, defaultRolesFieldValue, deleteLibraryColor, documentTitle, featureCatalogue, findColorTokens, findColorUsages, firstImageIn, getFeaturesMatrix, getRolesMatrix, hasCapability, hasFeature, hasRole, hideUnlessFeature, isAdmin, isAdminOrSelf, isAuthenticated, isDeveloper, isDeveloperTab, isFeatureSlug, isLockedFeature, isPackageFeatureSlug, isPrivilegedRole, isRole, isRoleSlug, lookField, noIndexField, normalizeSlug, normalizeStoredRoles, pageEditorLooks, pageEditorTokens, parseFeaturesMatrix, parseRolesMatrix, persistThemeChild, publishThemeChild, resetRolesMatrix, resolveThemeIdentity, rewriteColorToken, rewriteColorTokens, rewriteColorUsages, roleDescription, roleLabel, roleSelectOptions, sanitizeSvg, seedFeatures, seedRoleRows, seedRoles, seedTheme, seoPlugin, slugField, storedRoles, themeColorKeys, themeLibraryFromDoc, titleTemplate, truncateAtWord, validateFeaturesMatrix, validateRolesMatrix };
2099
+ 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 };
1976
2100
 
1977
2101
  //# sourceMappingURL=index.mjs.map