domma-cms 0.92.1 → 0.94.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.
Files changed (177) hide show
  1. package/CLAUDE.md +5 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -1
  5. package/admin/js/lib/api-tokens-arrange.js +2 -2
  6. package/admin/js/lib/block-editor-arrange.js +1 -1
  7. package/admin/js/lib/blocks-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/components-arrange.js +1 -1
  10. package/admin/js/lib/dashboard-arrange.js +1 -1
  11. package/admin/js/lib/dates.js +1 -0
  12. package/admin/js/lib/forms-arrange.js +1 -1
  13. package/admin/js/lib/media-arrange.js +1 -1
  14. package/admin/js/lib/notifications-arrange.js +1 -1
  15. package/admin/js/lib/pages-arrange.js +1 -1
  16. package/admin/js/lib/related.js +1 -1
  17. package/admin/js/lib/timeline-builder.js +2 -2
  18. package/admin/js/templates/action-editor.html +6 -5
  19. package/admin/js/templates/actions-list.html +1 -1
  20. package/admin/js/templates/contacts.html +1 -1
  21. package/admin/js/templates/docs/api-actions.html +86 -60
  22. package/admin/js/templates/docs/api-authentication.html +159 -123
  23. package/admin/js/templates/docs/api-builder.html +197 -0
  24. package/admin/js/templates/docs/api-collections.html +199 -259
  25. package/admin/js/templates/docs/api-external.html +225 -0
  26. package/admin/js/templates/docs/api-forms.html +268 -0
  27. package/admin/js/templates/docs/api-layouts.html +70 -45
  28. package/admin/js/templates/docs/api-media.html +57 -80
  29. package/admin/js/templates/docs/api-navigation.html +66 -22
  30. package/admin/js/templates/docs/api-pages.html +109 -129
  31. package/admin/js/templates/docs/api-plugins.html +123 -61
  32. package/admin/js/templates/docs/api-scaffold.html +185 -0
  33. package/admin/js/templates/docs/api-settings.html +72 -64
  34. package/admin/js/templates/docs/api-users.html +74 -107
  35. package/admin/js/templates/docs/api-views.html +68 -54
  36. package/admin/js/templates/docs/components-howto.html +20 -17
  37. package/admin/js/templates/docs/components-reference.html +13 -16
  38. package/admin/js/templates/docs/components-rules.html +7 -6
  39. package/admin/js/templates/docs/components-walkthrough.html +19 -19
  40. package/admin/js/templates/docs/tutorial-crud.html +71 -40
  41. package/admin/js/templates/docs/tutorial-forms.html +51 -35
  42. package/admin/js/templates/docs/tutorial-plugin.html +132 -56
  43. package/admin/js/templates/docs/usage-actions.html +61 -15
  44. package/admin/js/templates/docs/usage-collections.html +108 -0
  45. package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
  46. package/admin/js/templates/docs/usage-dconfig.html +0 -3
  47. package/admin/js/templates/docs/usage-editions.html +213 -0
  48. package/admin/js/templates/docs/usage-media.html +22 -6
  49. package/admin/js/templates/docs/usage-navigation.html +74 -18
  50. package/admin/js/templates/docs/usage-pages.html +60 -20
  51. package/admin/js/templates/docs/usage-plugins.html +89 -17
  52. package/admin/js/templates/docs/usage-shortcodes.html +123 -70
  53. package/admin/js/templates/docs/usage-site-settings.html +50 -18
  54. package/admin/js/templates/docs/usage-tools.html +73 -0
  55. package/admin/js/templates/docs/usage-users-roles.html +99 -20
  56. package/admin/js/templates/docs/usage-views.html +36 -19
  57. package/admin/js/templates/documentation.html +153 -32
  58. package/admin/js/templates/page-editor.html +0 -5
  59. package/admin/js/templates/plugin-guide.html +15 -0
  60. package/admin/js/templates/plugin-guides.html +21 -0
  61. package/admin/js/templates/pro-docs.html +53 -234
  62. package/admin/js/templates/tutorials.html +5 -4
  63. package/admin/js/views/actions-list.js +3 -3
  64. package/admin/js/views/analytics.js +5 -5
  65. package/admin/js/views/api-endpoint-editor.js +2 -2
  66. package/admin/js/views/block-editor.js +4 -4
  67. package/admin/js/views/blocks.js +4 -4
  68. package/admin/js/views/collection-editor.js +4 -4
  69. package/admin/js/views/collection-entries.js +7 -7
  70. package/admin/js/views/component-editor.js +2 -2
  71. package/admin/js/views/contacts.js +22 -20
  72. package/admin/js/views/context-menu-editor.js +5 -5
  73. package/admin/js/views/doc-pages.js +1 -1
  74. package/admin/js/views/form-editor.js +4 -4
  75. package/admin/js/views/form-submissions.js +2 -2
  76. package/admin/js/views/index.js +1 -1
  77. package/admin/js/views/media.js +3 -3
  78. package/admin/js/views/menu-editor.js +13 -13
  79. package/admin/js/views/menu-locations.js +2 -2
  80. package/admin/js/views/my-profile.js +1 -1
  81. package/admin/js/views/page-editor.js +8 -8
  82. package/admin/js/views/plugin-guides.js +5 -0
  83. package/admin/js/views/project-detail.js +2 -2
  84. package/admin/js/views/project-settings.js +1 -1
  85. package/admin/js/views/role-editor.js +4 -4
  86. package/admin/js/views/search.js +2 -2
  87. package/admin/js/views/seo.js +17 -17
  88. package/admin/js/views/settings.js +3 -3
  89. package/admin/js/views/theme.js +3 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +2 -2
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/bin/cli.js +13 -13
  94. package/bin/lib/node-version.js +29 -0
  95. package/package.json +1 -1
  96. package/plugins/_lib/admin/mail/compose-window.js +3 -2
  97. package/plugins/_lib/admin/mail/reader-view.js +7 -6
  98. package/plugins/_lib/admin/mail/scheduling.js +4 -2
  99. package/plugins/_lib/admin/mail/templates.js +4 -4
  100. package/plugins/_lib/admin/ui/dates.js +85 -0
  101. package/plugins/blog/CLAUDE.md +31 -22
  102. package/plugins/blog/admin/views/blog.js +3 -2
  103. package/plugins/blog/admin/views/comments.js +2 -1
  104. package/plugins/blog/admin/views/post-editor.js +4 -4
  105. package/plugins/blog/blocks/blog-card-row.html +1 -1
  106. package/plugins/blog/blocks/blog-card.html +2 -2
  107. package/plugins/blog/blocks/blog-post-classic.html +2 -2
  108. package/plugins/blog/blocks/blog-post-essay.html +2 -2
  109. package/plugins/blog/blocks/blog-post-feature.html +2 -2
  110. package/plugins/blog/blocks/blog-post-minimal.html +2 -2
  111. package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
  112. package/plugins/blog/blocks/blog-post-split.html +2 -2
  113. package/plugins/blog/docs/guide.md +205 -0
  114. package/plugins/blog/lib/layouts.js +3 -3
  115. package/plugins/blog/lib/page.js +2 -1
  116. package/plugins/blog/plugin.js +3 -3
  117. package/plugins/blog/plugin.json +4 -4
  118. package/plugins/blog/tests/layouts.test.js +6 -0
  119. package/plugins/feedback/CLAUDE.md +22 -3
  120. package/plugins/feedback/admin/lib/kit.js +6 -7
  121. package/plugins/feedback/admin/views/feedback.js +79 -10
  122. package/plugins/feedback/admin/views/send.js +28 -6
  123. package/plugins/feedback/docs/guide.md +95 -0
  124. package/plugins/feedback/lib/receiver.js +9 -2
  125. package/plugins/feedback/lib/sender.js +3 -2
  126. package/plugins/feedback/plugin.js +54 -6
  127. package/plugins/feedback/plugin.json +4 -4
  128. package/plugins/feedback/tests/api.test.js +74 -2
  129. package/plugins/free-tier.lock.json +49 -44
  130. package/plugins/mail-reader/CLAUDE.md +33 -18
  131. package/plugins/mail-reader/docs/guide.md +147 -0
  132. package/plugins/mail-reader/plugin.json +1 -1
  133. package/plugins/security/CLAUDE.md +4 -1
  134. package/plugins/security/admin/views/security.js +5 -5
  135. package/plugins/security/docs/guide.md +170 -0
  136. package/plugins/security/plugin.js +2 -1
  137. package/plugins/security/plugin.json +2 -1
  138. package/plugins/shopping-cart/CLAUDE.md +7 -1
  139. package/plugins/shopping-cart/admin/lib/kit.js +5 -2
  140. package/plugins/shopping-cart/admin/views/orders.js +4 -4
  141. package/plugins/shopping-cart/admin/views/overview.js +2 -2
  142. package/plugins/shopping-cart/docs/guide.md +191 -0
  143. package/plugins/shopping-cart/lib/render.js +2 -1
  144. package/plugins/shopping-cart/plugin.json +3 -3
  145. package/public/js/collection-browser.js +2 -2
  146. package/public/js/site.js +1 -1
  147. package/scripts/gen-instance-secret.js +3 -1
  148. package/scripts/setup.js +3 -1
  149. package/server/middleware/auth.js +2 -1
  150. package/server/routes/api/actions.js +47 -27
  151. package/server/routes/api/blocks.js +2 -1
  152. package/server/routes/api/collections.js +16 -52
  153. package/server/routes/api/contacts.js +66 -3
  154. package/server/routes/api/documentation.js +42 -0
  155. package/server/routes/api/notifications.js +3 -2
  156. package/server/routes/api/users.js +10 -6
  157. package/server/server.js +16 -1
  158. package/server/services/actions.js +110 -34
  159. package/server/services/adapterRegistry.js +169 -16
  160. package/server/services/adapters/FileAdapter.js +25 -0
  161. package/server/services/adapters/MongoAdapter.js +23 -0
  162. package/server/services/collections.js +104 -1
  163. package/server/services/connectionManager.js +12 -0
  164. package/server/services/dates.js +81 -0
  165. package/server/services/docs.js +13 -2
  166. package/server/services/markdown.js +75 -26
  167. package/server/services/notification-sources.js +6 -5
  168. package/server/services/passwordReset.js +2 -1
  169. package/server/services/permissionRegistry.js +3 -2
  170. package/server/services/pluginGuides.js +255 -0
  171. package/server/services/pluginInstaller.js +54 -13
  172. package/server/services/plugins.js +29 -1
  173. package/server/services/presetCollections.js +31 -5
  174. package/server/services/renderer.js +2 -2
  175. package/server/services/sidebarBadges.js +3 -1
  176. package/server/services/tools.js +4 -2
  177. package/server/templates/page.html +2 -2
@@ -50,6 +50,7 @@ import {
50
50
  importEntries,
51
51
  listCollections,
52
52
  listEntries,
53
+ migrateStorage,
53
54
  updateCollection,
54
55
  updateEntry
55
56
  } from '../../services/collections.js';
@@ -538,55 +539,15 @@ export async function collectionsRoutes(fastify) {
538
539
  // Storage migration
539
540
  // -------------------------------------------------------------------------
540
541
 
542
+ // Entries keep their ids and meta dates; see migrateStorage() in services/collections.js.
541
543
  fastify.post('/collections/:slug/migrate-storage', canUpdate, async (request, reply) => {
542
- const {slug} = request.params;
543
- const {storage} = request.body || {};
544
-
545
- if (!storage?.adapter) {
546
- return reply.status(400).send({error: 'storage.adapter is required'});
547
- }
548
-
549
- const schema = await getCollection(slug);
550
- if (!schema) return reply.status(404).send({error: 'Collection not found'});
551
-
552
- const sourceAdapter = schema.storage?.adapter || 'file';
553
- if (sourceAdapter === storage.adapter) {
554
- return reply.status(400).send({error: 'Source and target adapters are the same'});
555
- }
556
-
557
- // Step 1: read all existing entries from current adapter BEFORE schema change
558
- const {entries} = await listEntries(slug, {limit: 1000000, sort: 'createdAt', order: 'asc'});
559
-
560
- // Step 2: update schema to new adapter (also invalidates the adapter cache)
561
- await updateCollection(slug, {...schema, storage});
562
-
563
- // Step 3: insert all entries into the new adapter
564
- let migrated = 0;
565
- for (const entry of entries) {
566
- try {
567
- await createEntry(slug, entry.data, {
568
- createdBy: entry.meta?.createdBy || null,
569
- source: 'migration'
570
- });
571
- migrated++;
572
- } catch (err) {
573
- fastify.log.warn(`[migrate-storage] Entry ${entry.id} skipped: ${err.message}`);
574
- }
575
- }
576
-
577
- // Step 4: if migrating away from file storage, archive the old data.json
578
- if (sourceAdapter === 'file') {
579
- try {
580
- const {rename} = await import('fs/promises');
581
- const {join} = await import('path');
582
- const dataPath = join(process.cwd(), 'content', 'collections', slug, 'data.json');
583
- await rename(dataPath, dataPath + '.bak');
584
- } catch {
585
- // data.json may not exist or rename already done
586
- }
544
+ try {
545
+ return await migrateStorage(request.params.slug, request.body?.storage);
546
+ } catch (err) {
547
+ const status = err.statusCode || 500;
548
+ if (status >= 500) fastify.log.error(`[migrate-storage] ${request.params.slug}: ${err.message}`);
549
+ return reply.status(status).send({error: err.message, ...(err.collisions && {collisions: err.collisions})});
587
550
  }
588
-
589
- return {migrated, total: entries.length};
590
551
  });
591
552
 
592
553
  // -------------------------------------------------------------------------
@@ -704,7 +665,10 @@ export async function collectionsRoutes(fastify) {
704
665
  * Body: { attrs: <base64 JSON of the original shortcode attributes> }
705
666
  * Auth: JWT required - `createdBy = <user.id>` is injected server-side
706
667
  * (the client cannot tamper with which user's data they see).
707
- * Returns: { html: '<div class="dm-collection-…">…</div>' }
668
+ * Returns: { html: '<div class="dm-collection-…">…</div>' } - a
669
+ * Collection Browser shell (`scope: 'mine'` in its config) when
670
+ * the attrs are interactive, so `transitions` works; otherwise
671
+ * the static fragment.
708
672
  *
709
673
  * Security: results are always scoped to `createdBy = <user.id>` -
710
674
  * a caller can only ever render their own entries, regardless of the
@@ -787,10 +751,10 @@ export async function collectionsRoutes(fastify) {
787
751
  const schema = await getCollection(attrs.slug);
788
752
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
789
753
 
790
- const { renderCollectionFragment } = await import('../../services/markdown.js');
791
- const html = await renderCollectionFragment(attrs, {
792
- extraFilter: { createdBy: decoded.id }
793
- });
754
+ // Interactive attrs come back as a Collection Browser shell scoped to
755
+ // this viewer (transitions included); the rest as a static fragment.
756
+ const { renderScopedCollection } = await import('../../services/markdown.js');
757
+ const html = await renderScopedCollection(attrs, decoded.id);
794
758
 
795
759
  return { html };
796
760
  });
@@ -12,6 +12,11 @@
12
12
  * is a behaviour change, and this was a relocation. A test pins it so the next
13
13
  * reader finds it stated rather than inferred.
14
14
  *
15
+ * Because a group is shared, renaming or deleting one rewrites every user's
16
+ * contacts. So only the group's creator (`meta.createdBy`, recorded on new
17
+ * groups) or a holder of `contacts.manageGroups` may do either; a group from
18
+ * before creators were recorded belongs to the permission holders alone.
19
+ *
15
20
  * Hand-filtered, as the plugin did it, rather than via rowAccess.js - the right
16
21
  * end state, and again not something to smuggle into a move.
17
22
  *
@@ -27,6 +32,30 @@
27
32
  import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
28
33
  import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
29
34
  import {hooks} from '../../services/hooks.js';
35
+ import {getPermissionsFor} from '../../services/roles.js';
36
+ import {getEffectiveRoles} from '../../services/userRoles.js';
37
+
38
+ /**
39
+ * The permission that lets someone rename or delete a group they did not
40
+ * create. Groups are shared, so a rename or delete changes every user's
41
+ * contacts; without it only the group's creator may do either. Level 0 holds
42
+ * it like everything else; admin is granted it once at boot (server.js).
43
+ */
44
+ export const MANAGE_GROUPS = 'manageGroups';
45
+
46
+ /**
47
+ * Does the caller hold `contacts.manageGroups`? The same union-of-roles test
48
+ * requirePermission() makes, done in the handler because the answer only
49
+ * matters when the caller is not the group's creator.
50
+ *
51
+ * @param {object} request
52
+ * @returns {boolean}
53
+ */
54
+ function defaultHoldsManageGroups(request) {
55
+ if (!request.user) return false;
56
+ const allowed = getPermissionsFor('contacts', MANAGE_GROUPS);
57
+ return getEffectiveRoles(request.user).some(r => allowed.includes(r));
58
+ }
30
59
 
31
60
  const CONTACTS_SLUG = 'contacts-contacts';
32
61
  const GROUPS_SLUG = 'contacts-groups';
@@ -217,7 +246,7 @@ function toGroupName(entry) {
217
246
  export async function contactsRoutes(fastify, opts = {}) {
218
247
  const authenticate = opts.authenticate || defaultAuthenticate;
219
248
  const requirePermission = opts.requirePermission || defaultRequirePermission;
220
-
249
+ const holdsManageGroups = opts.holdsManageGroups || defaultHoldsManageGroups;
221
250
 
222
251
  function userId(request) {
223
252
  return request.user?.id ?? request.user?.sub ?? null;
@@ -232,9 +261,22 @@ export async function contactsRoutes(fastify, opts = {}) {
232
261
 
233
262
  async function loadGroups() {
234
263
  const {entries} = await listEntries(GROUPS_SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
235
- return entries.map(e => ({id: e.id, name: toGroupName(e)}));
264
+ return entries.map(e => ({id: e.id, name: toGroupName(e), createdBy: e.meta?.createdBy ?? null}));
265
+ }
266
+
267
+ /**
268
+ * May this caller rename or delete this group? Its creator may; so may
269
+ * anyone holding contacts.manageGroups. A group from before creators were
270
+ * recorded has none, so only permission holders can touch it.
271
+ */
272
+ function canManageGroup(request, group) {
273
+ const uid = userId(request);
274
+ if (uid && group.createdBy && group.createdBy === uid) return true;
275
+ return holdsManageGroups(request) === true;
236
276
  }
237
277
 
278
+ const NOT_YOURS = 'Only the person who created this group, or someone allowed to manage every group, can rename or delete it.';
279
+
238
280
  /**
239
281
  * Build the stored shape from whatever a caller sent.
240
282
  *
@@ -396,6 +438,24 @@ export async function contactsRoutes(fastify, opts = {}) {
396
438
  return reply.send(groups.map(g => g.name));
397
439
  });
398
440
 
441
+ /**
442
+ * GET /api/contacts/groups/access - who may do what with each group, for
443
+ * the screens. `/groups` stays a list of names: other Tools read it.
444
+ * `{manageAll, groups: [{name, mine, canManage}]}`
445
+ */
446
+ fastify.get('/groups/access', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
447
+ const uid = userId(request);
448
+ const manageAll = holdsManageGroups(request) === true;
449
+ const groups = await loadGroups();
450
+ return reply.send({
451
+ manageAll,
452
+ groups: groups.map(g => {
453
+ const mine = Boolean(uid && g.createdBy && g.createdBy === uid);
454
+ return {name: g.name, mine, canManage: mine || manageAll};
455
+ })
456
+ });
457
+ });
458
+
399
459
  /** POST /api/contacts/groups */
400
460
  fastify.post('/groups', {preHandler: [authenticate, requirePermission('contacts', 'create')]}, async (request, reply) => {
401
461
  const { name } = request.body ?? {};
@@ -410,7 +470,8 @@ export async function contactsRoutes(fastify, opts = {}) {
410
470
  return reply.code(400).send({ error: 'Group already exists' });
411
471
  }
412
472
 
413
- await createEntry(GROUPS_SLUG, {name: trimmed}, {source: 'admin'});
473
+ // The creator is recorded: it is who may rename or delete the group.
474
+ await createEntry(GROUPS_SLUG, {name: trimmed}, {createdBy: userId(request), source: 'admin'});
414
475
  return reply.code(201).send({ name: trimmed });
415
476
  });
416
477
 
@@ -426,6 +487,7 @@ export async function contactsRoutes(fastify, opts = {}) {
426
487
  const groups = await loadGroups();
427
488
  const grpEntry = groups.find(g => g.name === oldName);
428
489
  if (!grpEntry) return reply.code(404).send({error: 'Group not found'});
490
+ if (!canManageGroup(request, grpEntry)) return reply.code(403).send({error: NOT_YOURS});
429
491
 
430
492
  const trimmed = newName.trim();
431
493
  if (groups.some(g => g.name === trimmed)) {
@@ -457,6 +519,7 @@ export async function contactsRoutes(fastify, opts = {}) {
457
519
  const groups = await loadGroups();
458
520
  const grpEntry = groups.find(g => g.name === name);
459
521
  if (!grpEntry) return reply.code(404).send({error: 'Group not found'});
522
+ if (!canManageGroup(request, grpEntry)) return reply.code(403).send({error: NOT_YOURS});
460
523
 
461
524
  // Remove the group entry
462
525
  await deleteEntry(GROUPS_SLUG, grpEntry.id);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Documentation API - plugin guides for the admin's Documentation folder.
3
+ *
4
+ * GET /api/documentation/plugins - the guides this user may read
5
+ * GET /api/documentation/plugins/:name - one guide, its first page rendered
6
+ * GET /api/documentation/plugins/:name/:page - one guide, the named page rendered
7
+ *
8
+ * Signed-in users only. A guide is readable by whoever can use the plugin
9
+ * (services/pluginGuides.js decides), so a role that cannot open Invoices is
10
+ * not shown the Invoices guide either.
11
+ */
12
+ import {authenticate as defaultAuthenticate} from '../../middleware/auth.js';
13
+ import {getPermissionsForRoles} from '../../services/roles.js';
14
+ import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
15
+ import {getEffectiveRoles} from '../../services/userRoles.js';
16
+ import {getGuide, listGuides} from '../../services/pluginGuides.js';
17
+
18
+ /** The caller's permissions - the same union /api/auth/permissions answers. */
19
+ function permissionsOf(user, resolve) {
20
+ if (resolve) return resolve(user);
21
+ const roles = getEffectiveRoles(user || {});
22
+ return getPermissionsForRoles(roles, getEffectiveRegistry().map(r => r.key));
23
+ }
24
+
25
+ export async function documentationRoutes(fastify, opts = {}) {
26
+ const authenticate = opts.authenticate || defaultAuthenticate;
27
+ const signedIn = {preHandler: [authenticate]};
28
+
29
+ fastify.get('/documentation/plugins', signedIn, async (request) => {
30
+ return {guides: listGuides(permissionsOf(request.user, opts.permissionsOf))};
31
+ });
32
+
33
+ const one = async (request, reply) => {
34
+ const {name, page} = request.params;
35
+ const out = await getGuide(name, page, permissionsOf(request.user, opts.permissionsOf));
36
+ if (out.status === 403) return reply.code(403).send({statusCode: 403, error: 'Forbidden', message: 'You cannot use this plugin.'});
37
+ if (out.status !== 200) return reply.code(404).send({statusCode: 404, error: 'Not Found', message: 'No such guide.'});
38
+ return out.guide;
39
+ };
40
+ fastify.get('/documentation/plugins/:name', signedIn, one);
41
+ fastify.get('/documentation/plugins/:name/:page', signedIn, one);
42
+ }
@@ -27,6 +27,7 @@ import { randomUUID } from 'crypto';
27
27
  import { getAdapter } from '../../services/adapterRegistry.js';
28
28
  import {AUDIENCES, isActiveFor, listNotificationSources, runSource, saveSourceSetting} from '../../services/notify.js';
29
29
  import {getEffectiveRoles} from '../../services/userRoles.js';
30
+ import {fmtDate} from '../../services/dates.js';
30
31
 
31
32
  const SLUG = 'notifications';
32
33
 
@@ -100,7 +101,7 @@ export function notificationsBadge(active = []) {
100
101
  ],
101
102
  items: unread.slice(0, 6).map(e => ({
102
103
  text: String(e.data.title || '(no title)').slice(0, 80),
103
- meta: [sev(e), String(e.data.createdAt || '').slice(0, 10)].filter(Boolean).join(' · '),
104
+ meta: [sev(e), fmtDate(e.data.createdAt)].filter(Boolean).join(' · '),
104
105
  ...(sev(e) === 'critical' && {tone: 'danger'})
105
106
  })),
106
107
  empty: 'Nothing unread.'
@@ -121,7 +122,7 @@ export function notificationsBadge(active = []) {
121
122
  */
122
123
  export function sampleNotifications(now = new Date(), origin = '', forUser = null) {
123
124
  const expiresAt = new Date(now.getTime() + 7 * 86_400_000).toISOString();
124
- const note = `\n\n(A sample - it goes by itself on ${expiresAt.slice(0, 10)}.)`;
125
+ const note = `\n\n(A sample - it goes by itself on ${fmtDate(expiresAt, {utc: true})}.)`;
125
126
  const samples = [
126
127
  {severity: 'critical', title: 'Plugin failed to load: Calendar',
127
128
  body: 'The Calendar plugin stopped with an error while the site started, so its screens and pages are missing. Check Plugins for the error, then restart the site.',
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Users API
3
- * GET /api/users - list all users (admin, manager)
3
+ * Access is by the `users` permission (read/create/update/delete - which roles
4
+ * hold it is role data), plus canManageUser(): you can only create, change or
5
+ * delete an account whose role is junior to yours.
6
+ *
7
+ * GET /api/users - list all users (users.read)
4
8
  * GET /api/users/_count - the sidebar badge (ids are uuids - nothing can shadow it)
5
- * GET /api/users/:id - single user (admin, manager, or self)
6
- * POST /api/users - create user (admin, manager - manager cannot create admin)
7
- * PUT /api/users/:id - update user (admin, manager - manager cannot edit admin)
8
- * DELETE /api/users/:id - delete user (admin, manager - manager cannot delete admin, no self-delete)
9
+ * GET /api/users/:id - single user (users.read, or self)
10
+ * POST /api/users - create user (users.create; only a role junior to yours)
11
+ * PUT /api/users/:id - update user (users.update; only a junior account)
12
+ * DELETE /api/users/:id - delete user (users.delete; only a junior account, no self-delete)
9
13
  * POST /api/users/:id/password-reset - {via: 'email'|'link'} - email them a reset link, or return one to copy
10
14
  * DELETE /api/users/:id/password-reset - withdraw an unused reset link
11
15
  * Both: users.update and a more senior role than theirs (canManageUser); never your own account.
@@ -83,7 +87,7 @@ export async function usersRoutes(fastify) {
83
87
  return usersBadge((await listUsers()).filter(u => canSeeArtefact(request.user, u)));
84
88
  });
85
89
 
86
- // Get single user (admin, manager, or the user themselves)
90
+ // Get single user (users.read, or the user themselves)
87
91
  fastify.get('/users/:id', { preHandler: [authenticate] }, async (request, reply) => {
88
92
  const { id } = request.params;
89
93
  const actor = request.user;
package/server/server.js CHANGED
@@ -21,7 +21,7 @@ import {createRequire} from 'module';
21
21
  import {config, getConfig} from './config.js';
22
22
  import {getLoadedPlugins, getPluginSettings, recordPluginLoadFailure, registerPlugins} from './services/plugins.js';
23
23
  import {notifierFor} from './services/notify.js';
24
- import {load as loadRoles, seed as seedRoles} from './services/roles.js';
24
+ import {grantPermissionOnce, load as loadRoles, seed as seedRoles} from './services/roles.js';
25
25
  import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
26
26
  import {seedAll as seedPresetCollections} from './services/presetCollections.js';
27
27
  import {seedCoreProject, seedDocsProject} from './services/projects.js';
@@ -231,6 +231,9 @@ try {
231
231
 
232
232
  await seedRoles();
233
233
  await loadRoles();
234
+ // Renaming or deleting a shared contact group is for its creator or a holder of
235
+ // contacts.manageGroups; admins get it once - removed by hand, it stays removed.
236
+ await grantPermissionOnce('admin', 'contacts.manageGroups', 'core').catch(() => {});
234
237
  await seedUserProfiles();
235
238
  await ensureAllProfiles();
236
239
  await seedPresetCollections();
@@ -484,6 +487,7 @@ const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
484
487
  const {apiEndpointsRoutes} = await import('./routes/api/api-endpoints.js');
485
488
  const {endpointsPublicRoutes} = await import('./routes/api/endpoints-public.js');
486
489
  const {sidebarRoutes} = await import('./routes/api/sidebar.js');
490
+ const {documentationRoutes} = await import('./routes/api/documentation.js');
487
491
  const { mediaRoutes } = await import('./routes/api/media.js');
488
492
  const { usersRoutes } = await import('./routes/api/users.js');
489
493
  const { pluginsRoutes } = await import('./routes/api/plugins.js');
@@ -522,6 +526,7 @@ await app.register(apiTokensRoutes, {prefix: '/api'});
522
526
  await app.register(apiEndpointsRoutes, {prefix: '/api'});
523
527
  await app.register(endpointsPublicRoutes, {prefix: '/api'});
524
528
  await app.register(sidebarRoutes, {prefix: '/api'});
529
+ await app.register(documentationRoutes, {prefix: '/api'});
525
530
  await app.register(mediaRoutes, { prefix: '/api' });
526
531
  await app.register(usersRoutes, { prefix: '/api' });
527
532
  await app.register(pluginsRoutes, { prefix: '/api' });
@@ -630,6 +635,16 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts
630
635
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
631
636
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
632
637
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
638
+ // Documentation pages added after the sidebar menu was seeded (0.93.1). The menu is
639
+ // seeded once, so new pages reach existing sites only by registering here. The two
640
+ // sub-folders have no key in older menus; findFolder() falls back to their name.
641
+ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Collections & Forms', url: '#/docs/usage/collections', icon: 'database'}});
642
+ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Built-in Tools', url: '#/docs/usage/tools', icon: 'tool'}});
643
+ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions & Licences', url: '#/docs/usage/editions', icon: 'key'}});
644
+ registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Forms', url: '#/docs/api/forms', icon: 'layout'}});
645
+ registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Scaffold', url: '#/docs/api/scaffold', icon: 'package'}});
646
+ registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'External API & Tokens', url: '#/docs/api/external', icon: 'key'}});
647
+ registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'API Builder', url: '#/docs/api/builder', icon: 'code'}});
633
648
 
634
649
  // Core's notification sources, before plugins add theirs (0.80).
635
650
  const {registerCoreSources} = await import('./services/notification-sources.js');
@@ -29,10 +29,27 @@
29
29
  import {v4 as uuidv4} from 'uuid';
30
30
  import {createEntry, deleteEntry, getEntry, updateEntry} from './collections.js';
31
31
  import {checkEntryAccess} from './rowAccess.js';
32
+ import {canReadView} from './views.js';
33
+ import {getEffectiveLevel} from './userRoles.js';
32
34
 
33
35
  /** MongoDB collection where action configs are stored. */
34
36
  const ACTIONS_COLLECTION = 'cms__actions';
35
37
 
38
+ /** A Db handed in by tests (useActionsDb); null means the 'default' connection. */
39
+ let testDb = null;
40
+
41
+ /**
42
+ * Tests only: store action configs in `db` (anything with Mongo's
43
+ * collection().find/findOne/insertOne/replaceOne/deleteOne) instead of the
44
+ * 'default' connection, so the routes can be driven without MongoDB. Pass
45
+ * null to go back.
46
+ *
47
+ * @param {object|null} db
48
+ */
49
+ export function useActionsDb(db) {
50
+ testDb = db || null;
51
+ }
52
+
36
53
  // ---------------------------------------------------------------------------
37
54
  // Internal helpers
38
55
  // ---------------------------------------------------------------------------
@@ -44,6 +61,7 @@ const ACTIONS_COLLECTION = 'cms__actions';
44
61
  * @throws {Error} If MongoDB is not configured
45
62
  */
46
63
  async function getMetaDb() {
64
+ if (testDb) return testDb;
47
65
  try {
48
66
  const { getDb } = await import('./connectionManager.js');
49
67
  return getDb('default');
@@ -415,57 +433,110 @@ export async function listActionsForCollection(collectionSlug) {
415
433
  .toArray();
416
434
  }
417
435
 
436
+ /**
437
+ * May this user run the action (public execute) or be offered it as a
438
+ * transition? Any role in `access.roles` is enough, each on its usual ladder
439
+ * (the role and every more senior one; `=role` exact), across every role the
440
+ * user holds - the rule views use, so this IS canReadView(), not a third
441
+ * implementation.
442
+ *
443
+ * - A role name this site does not have admits nobody but the level-0 role
444
+ * (checkVisibility's rule for an unknown name), so a deleted or mistyped
445
+ * role never opens the action to everyone. `public` is not a role here:
446
+ * running an action always needs a signed-in user, so it counts as unknown.
447
+ * - No roles named (missing or empty list) is the admin tier, levels 0 and 1 -
448
+ * what the old `['admin', 'super-admin']` default names, and what the
449
+ * Actions list shows ("admins").
450
+ *
451
+ * @param {object|null} user - {role, additionalRoles}
452
+ * @param {object|null} action - action config
453
+ * @returns {boolean}
454
+ */
455
+ export function canRunAction(user, action) {
456
+ if (!user?.role) return false;
457
+ const roles = Array.isArray(action?.access?.roles)
458
+ ? action.access.roles.filter(r => typeof r === 'string' && r)
459
+ : [];
460
+ if (!roles.length) return canReadView(user, {access: {roles: []}});
461
+ const named = roles.filter(r => r !== 'public');
462
+ if (!named.length) return getEffectiveLevel(user) === 0;
463
+ return canReadView(user, {access: {roles: named}});
464
+ }
465
+
418
466
  /**
419
467
  * List the actions that represent valid next-state transitions for a given
420
468
  * entry as seen by a given user. Used by the per-row transitions UI in the
421
- * Collection Browser and the `[transitions]` shortcode.
469
+ * Collection Browser (an interactive `[collection … transitions]` on a page).
422
470
  *
423
471
  * Criteria for inclusion:
424
472
  * 1. action.collection matches the entry's collection
425
473
  * 2. action.transition is set and action.transition.from includes the
426
474
  * entry's current value of action.transition.field
427
- * 3. action.access.roles allows the user (compared by role level so
428
- * higher-privileged roles inherit access automatically)
475
+ * 3. canRunAction(): any of action.access.roles admits the user, on its
476
+ * ladder (more senior roles inherit it)
429
477
  *
430
478
  * Returns a stripped projection - slug, title, trigger config, transition -
431
479
  * so the client can render buttons without leaking step internals.
432
480
  *
433
481
  * @param {string} collectionSlug
434
482
  * @param {object} entry - The entry being inspected (needed for field-value match)
435
- * @param {object} [user] - { role } - anonymous if missing
483
+ * @param {object} [user] - { id, role, additionalRoles } - anonymous if missing
484
+ * @param {object} [opts]
485
+ * @param {string} [opts.scope] - 'mine': the row is offered nothing unless it is
486
+ * in the viewer's scope (entryInScope)
436
487
  * @returns {Promise<object[]>}
437
488
  */
438
- export async function listTransitionsForEntry(collectionSlug, entry, user = null) {
489
+ export async function listTransitionsForEntry(collectionSlug, entry, user = null, {scope} = {}) {
490
+ if (!entryInScope(entry, user, scope)) return [];
439
491
  const all = await listActionsForCollection(collectionSlug);
440
492
  if (!Array.isArray(all) || !all.length) return [];
441
493
 
442
- const { getRoleLevel } = await import('./roles.js');
443
- const { getEffectiveLevel } = await import('./userRoles.js');
444
- // Use effective level so multi-role users see transitions any of their
445
- // roles permit - e.g. a user who's primarily a candidate but also an
446
- // admin sees BOTH the candidate-only "withdraw" AND the admin-only
447
- // "approve" transitions on the same entry.
448
- const userLevel = getEffectiveLevel(user);
449
-
450
- return all
451
- .filter(a => {
452
- if (!a.transition) return false;
453
- const allowedFrom = Array.isArray(a.transition.from) ? a.transition.from : [a.transition.from].filter(Boolean);
454
- if (allowedFrom.length) {
455
- const current = entry.data?.[a.transition.field];
456
- if (!allowedFrom.includes(current)) return false;
457
- }
458
- const roles = a.access?.roles || ['admin', 'super-admin'];
459
- const minLevel = Math.min(...roles.map(r => getRoleLevel(r)));
460
- if (!(userLevel <= minLevel)) return false;
461
- return true;
462
- })
463
- .map(a => ({
464
- slug: a.slug,
465
- title: a.title,
466
- trigger: a.trigger,
467
- transition: a.transition
468
- }));
494
+ // canRunAction walks every role the user holds, so a user who is primarily
495
+ // a candidate but also an admin sees BOTH the candidate-only "withdraw" AND
496
+ // the admin-only "approve" transitions on the same entry.
497
+ const offered = all.filter(a => {
498
+ if (!a.transition) return false;
499
+ const allowedFrom = Array.isArray(a.transition.from) ? a.transition.from : [a.transition.from].filter(Boolean);
500
+ if (allowedFrom.length) {
501
+ const current = entry.data?.[a.transition.field];
502
+ if (!allowedFrom.includes(current)) return false;
503
+ }
504
+ return canRunAction(user, a);
505
+ });
506
+
507
+ // Row-level rules too, so a button is only offered when executeAction()
508
+ // would let it through (a withdraw limited to the owner is not shown on
509
+ // someone else's row).
510
+ const kept = [];
511
+ for (const a of offered) {
512
+ if (await checkEntryAccess(entry, user, a.access?.rowLevel)) kept.push(a);
513
+ }
514
+ return kept.map(a => ({
515
+ slug: a.slug,
516
+ title: a.title,
517
+ trigger: a.trigger,
518
+ transition: a.transition
519
+ }));
520
+ }
521
+
522
+ /**
523
+ * Is this entry inside the display scope the request came from? The only scope
524
+ * is `mine` (`[collection scope="mine"]`): the entry's `meta.createdBy` must be
525
+ * the user's id - the filter render-scope and `GET /:slug/public?scope=mine`
526
+ * list with, so a viewer acts only on the rows that display showed them. No
527
+ * level bypass: scope="mine" shows an admin their own rows too. No scope leaves
528
+ * the decision to the action's own access; a scope this code does not know
529
+ * fails closed.
530
+ *
531
+ * @param {object|null} entry
532
+ * @param {object|null} user
533
+ * @param {string} [scope]
534
+ * @returns {boolean}
535
+ */
536
+ export function entryInScope(entry, user, scope) {
537
+ if (!scope) return true;
538
+ if (scope !== 'mine') return false;
539
+ return !!user?.id && entry?.meta?.createdBy === user.id;
469
540
  }
470
541
 
471
542
  // ---------------------------------------------------------------------------
@@ -481,17 +552,22 @@ export async function listTransitionsForEntry(collectionSlug, entry, user = null
481
552
  * @param {string} entryId - ID of the target entry
482
553
  * @param {object} [opts]
483
554
  * @param {object} [opts.user] - Executing user object (for template context)
555
+ * @param {string} [opts.scope] - 'mine' when run from a scope="mine" display:
556
+ * a row outside it is refused (403, entryInScope)
484
557
  * @returns {Promise<{ success: boolean, stepsCompleted: number, results: object[] }>}
485
558
  * @throws {Error} If action or entry not found
486
559
  */
487
- export async function executeAction(slug, entryId, { user = null } = {}) {
560
+ export async function executeAction(slug, entryId, { user = null, scope } = {}) {
488
561
  const action = await getAction(slug);
489
562
  if (!action) throw new Error(`Action "${slug}" not found`);
490
563
 
491
564
  const entry = await getEntry(action.collection, entryId);
492
565
  if (!entry) throw new Error(`Entry "${entryId}" not found in collection "${action.collection}"`);
493
566
 
494
- if (!(await checkEntryAccess(entry, user, action.access?.rowLevel))) {
567
+ // Out of the requesting display's scope (scope="mine": not the viewer's
568
+ // row) is refused exactly like a row-level denial, and before the state
569
+ // check, so an outside row never learns its state through a 409.
570
+ if (!entryInScope(entry, user, scope) || !(await checkEntryAccess(entry, user, action.access?.rowLevel))) {
495
571
  const err = new Error('Row-level access denied');
496
572
  err.statusCode = 403;
497
573
  throw err;