domma-cms 0.18.0 → 0.21.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 (110) hide show
  1. package/CLAUDE.md +37 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/lib/crud-tutorial.js +1 -0
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/lib/project-context.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +4 -0
  10. package/admin/js/templates/action-editor.html +7 -0
  11. package/admin/js/templates/block-editor.html +7 -0
  12. package/admin/js/templates/collection-editor.html +9 -0
  13. package/admin/js/templates/form-editor.html +9 -0
  14. package/admin/js/templates/menu-editor.html +98 -0
  15. package/admin/js/templates/menu-locations.html +14 -0
  16. package/admin/js/templates/menus.html +14 -0
  17. package/admin/js/templates/page-editor.html +9 -2
  18. package/admin/js/templates/project-detail.html +50 -0
  19. package/admin/js/templates/project-editor.html +45 -0
  20. package/admin/js/templates/project-settings.html +60 -0
  21. package/admin/js/templates/projects.html +13 -0
  22. package/admin/js/templates/role-editor.html +11 -0
  23. package/admin/js/templates/tutorials.html +335 -2
  24. package/admin/js/templates/view-editor.html +7 -0
  25. package/admin/js/views/action-editor.js +1 -1
  26. package/admin/js/views/actions-list.js +1 -1
  27. package/admin/js/views/block-editor.js +8 -8
  28. package/admin/js/views/blocks.js +2 -2
  29. package/admin/js/views/collection-editor.js +4 -4
  30. package/admin/js/views/collections.js +1 -1
  31. package/admin/js/views/form-editor.js +5 -5
  32. package/admin/js/views/forms.js +1 -1
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/menu-editor.js +19 -0
  35. package/admin/js/views/menu-locations.js +1 -0
  36. package/admin/js/views/menus.js +5 -0
  37. package/admin/js/views/page-editor.js +24 -24
  38. package/admin/js/views/pages.js +3 -3
  39. package/admin/js/views/project-detail.js +4 -0
  40. package/admin/js/views/project-editor.js +1 -0
  41. package/admin/js/views/project-settings.js +1 -0
  42. package/admin/js/views/projects.js +7 -0
  43. package/admin/js/views/role-editor.js +1 -1
  44. package/admin/js/views/roles.js +3 -3
  45. package/admin/js/views/tutorials.js +1 -1
  46. package/admin/js/views/user-editor.js +1 -1
  47. package/admin/js/views/users.js +3 -3
  48. package/admin/js/views/view-editor.js +1 -1
  49. package/admin/js/views/views-list.js +1 -1
  50. package/config/menu-locations.json +5 -0
  51. package/config/menus/admin-sidebar.json +185 -0
  52. package/config/menus/footer.json +33 -0
  53. package/config/menus/main.json +35 -0
  54. package/config/menus/sproj-1779696558011-menu.json +17 -0
  55. package/config/menus/sproj-1779696960337-menu.json +18 -0
  56. package/config/menus/sproj-1779696985353-menu.json +18 -0
  57. package/config/site.json +6 -22
  58. package/package.json +4 -3
  59. package/plugins/analytics/daily.json +3 -0
  60. package/plugins/analytics/journeys.json +8 -0
  61. package/plugins/analytics/lifetime.json +1 -1
  62. package/public/css/site.css +1 -1
  63. package/public/js/collection-browser.js +4 -0
  64. package/public/js/forms.js +1 -1
  65. package/public/js/site.js +1 -1
  66. package/server/middleware/auth.js +88 -22
  67. package/server/routes/api/actions.js +58 -5
  68. package/server/routes/api/auth.js +2 -2
  69. package/server/routes/api/blocks.js +18 -3
  70. package/server/routes/api/collections.js +201 -8
  71. package/server/routes/api/forms.js +266 -21
  72. package/server/routes/api/menu-locations.js +46 -0
  73. package/server/routes/api/menus.js +115 -0
  74. package/server/routes/api/pages.js +1 -1
  75. package/server/routes/api/projects.js +107 -0
  76. package/server/routes/api/scaffold.js +86 -0
  77. package/server/routes/api/sidebar.js +23 -0
  78. package/server/routes/api/users.js +32 -7
  79. package/server/routes/api/views.js +10 -2
  80. package/server/routes/public.js +79 -6
  81. package/server/server.js +38 -0
  82. package/server/services/actions.js +137 -8
  83. package/server/services/adapters/FileAdapter.js +23 -8
  84. package/server/services/adapters/MongoAdapter.js +36 -18
  85. package/server/services/blocks.js +20 -8
  86. package/server/services/collections.js +85 -8
  87. package/server/services/content.js +23 -9
  88. package/server/services/filterEngine.js +281 -0
  89. package/server/services/hooks.js +48 -0
  90. package/server/services/markdown.js +686 -109
  91. package/server/services/menus-migration.js +107 -0
  92. package/server/services/menus.js +422 -0
  93. package/server/services/permissionRegistry.js +26 -0
  94. package/server/services/plugins.js +9 -2
  95. package/server/services/presetCollections.js +22 -0
  96. package/server/services/projects.js +429 -0
  97. package/server/services/recipes/contact-list.json +78 -0
  98. package/server/services/recipes/onboarding.json +426 -0
  99. package/server/services/references.js +174 -0
  100. package/server/services/renderer.js +237 -40
  101. package/server/services/roles.js +6 -1
  102. package/server/services/rowAccess.js +86 -13
  103. package/server/services/scaffolder.js +465 -0
  104. package/server/services/sidebar-migration.js +117 -0
  105. package/server/services/sitemap.js +112 -0
  106. package/server/services/userRoles.js +86 -0
  107. package/server/services/users.js +23 -2
  108. package/server/services/views.js +15 -4
  109. package/server/templates/page.html +7 -2
  110. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * User Roles — multi-role support helpers.
3
+ *
4
+ * A user has ONE primary role (`user.role`) and an optional array of
5
+ * additional roles (`user.additionalRoles`). Permission, visibility, and
6
+ * row-access checks consult ALL of them and grant access if any one role
7
+ * satisfies — "any path wins" semantics that matches how most apps actually
8
+ * model multi-membership (you can be both a candidate AND a recruiter).
9
+ *
10
+ * Hierarchical-level comparisons (`canManageUser`, `requireAdmin`, the
11
+ * level-clamp in `checkSingleVisibility`) use `getEffectiveLevel` which
12
+ * returns the LOWEST level number across all the user's roles — i.e. the
13
+ * highest privilege the user has access to. This is what makes "additional
14
+ * admin" meaningfully grant admin-tier access without rewriting the level
15
+ * arithmetic everywhere.
16
+ *
17
+ * Backward compatibility: users with no `additionalRoles` field behave
18
+ * exactly as before — `getEffectiveRoles` returns `[user.role]`, `getEffectiveLevel`
19
+ * returns `getRoleLevel(user.role)`. Existing callers that read `user.role`
20
+ * directly continue to work; the new helpers are opt-in for callers that
21
+ * want the union semantics.
22
+ */
23
+ import {getRoleLevel} from './roles.js';
24
+
25
+ /**
26
+ * Return all roles a user holds, primary first, deduplicated. Empty array
27
+ * if the user is null/has no role at all.
28
+ *
29
+ * @param {object|null} user
30
+ * @returns {string[]}
31
+ */
32
+ export function getEffectiveRoles(user) {
33
+ if (!user || !user.role) return [];
34
+ const additional = Array.isArray(user.additionalRoles) ? user.additionalRoles : [];
35
+ const out = [user.role];
36
+ for (const r of additional) {
37
+ if (r && r !== user.role && !out.includes(r)) out.push(r);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Return the effective role level for a user — the LOWEST level number
44
+ * across all roles. Lower number = higher privilege, so the user's most
45
+ * privileged role wins all hierarchical checks.
46
+ *
47
+ * Returns `Infinity` for users with no roles (denies all level-gated checks).
48
+ *
49
+ * @param {object|null} user
50
+ * @returns {number}
51
+ */
52
+ export function getEffectiveLevel(user) {
53
+ const roles = getEffectiveRoles(user);
54
+ if (!roles.length) return Infinity;
55
+ return Math.min(...roles.map(r => getRoleLevel(r)));
56
+ }
57
+
58
+ /**
59
+ * Check whether a user holds a specific role (primary or additional).
60
+ * Exact-name match — does NOT walk the hierarchy. For hierarchical "user
61
+ * is at least this level" checks use `getEffectiveLevel` against the target
62
+ * role's level instead.
63
+ *
64
+ * @param {object|null} user
65
+ * @param {string} role
66
+ * @returns {boolean}
67
+ */
68
+ export function userHasRole(user, role) {
69
+ if (!user || !role) return false;
70
+ return getEffectiveRoles(user).includes(role);
71
+ }
72
+
73
+ /**
74
+ * Check whether a user holds ANY of the given roles. Convenience helper
75
+ * for "anyone in this set can do X" patterns — equivalent to OR-ing
76
+ * `userHasRole` over the list.
77
+ *
78
+ * @param {object|null} user
79
+ * @param {string[]} roles
80
+ * @returns {boolean}
81
+ */
82
+ export function userHasAnyRole(user, roles) {
83
+ if (!user || !Array.isArray(roles) || !roles.length) return false;
84
+ const userRoles = new Set(getEffectiveRoles(user));
85
+ return roles.some(r => userRoles.has(r));
86
+ }
@@ -9,6 +9,7 @@ import bcrypt from 'bcryptjs';
9
9
  import {v4 as uuidv4} from 'uuid';
10
10
  import {config} from '../config.js';
11
11
  import {deleteProfile, ensureProfile} from './userProfiles.js';
12
+ import * as cache from './cache/index.js';
12
13
 
13
14
  const USERS_DIR = path.resolve(config.content.usersDir);
14
15
 
@@ -122,7 +123,7 @@ export async function getUserByEmail(email) {
122
123
  * @param {object} data - { name, email, password, role }
123
124
  * @returns {Promise<object>} Created user (password stripped)
124
125
  */
125
- export async function createUser({ name, email, password, role = 'user' }) {
126
+ export async function createUser({ name, email, password, role = 'user', additionalRoles = [], meta, projects }) {
126
127
  const existing = await getUserByEmail(email);
127
128
  if (existing) throw new Error('A user with that email already exists');
128
129
 
@@ -134,10 +135,19 @@ export async function createUser({ name, email, password, role = 'user' }) {
134
135
  name: name.trim(),
135
136
  password: hash,
136
137
  role,
138
+ // Additional roles beyond the primary — permission/visibility checks
139
+ // grant access if ANY role satisfies. The primary `role` is what
140
+ // appears in UI badges and is used for hierarchical level comparisons
141
+ // when a single value is needed.
142
+ additionalRoles: Array.isArray(additionalRoles) ? additionalRoles.filter(r => r && r !== role) : [],
143
+ // Project access-scope: when non-empty, the user is restricted to artefacts
144
+ // tagged with one of these project slugs (see canSeeArtefact in projects.js).
145
+ projects: Array.isArray(projects) ? projects.filter(Boolean) : [],
137
146
  isActive: true,
138
147
  createdAt: now,
139
148
  updatedAt: now,
140
- lastLogin: null
149
+ lastLogin: null,
150
+ ...(meta != null && {meta})
141
151
  };
142
152
 
143
153
  await writeUserFile(user);
@@ -169,6 +179,17 @@ export async function updateUser(id, updates) {
169
179
  };
170
180
 
171
181
  await writeUserFile(updated);
182
+
183
+ // Invalidate per-user cache when the project access-scope changes — downstream
184
+ // request handlers cache scope-filtered responses keyed by `user:<id>`.
185
+ if (updates.projects !== undefined) {
186
+ const before = JSON.stringify(user.projects || []);
187
+ const after = JSON.stringify(updated.projects || []);
188
+ if (before !== after) {
189
+ await cache.invalidateTags([`user:${id}`]);
190
+ }
191
+ }
192
+
172
193
  return stripPassword(updated);
173
194
  }
174
195
 
@@ -137,7 +137,7 @@ export async function getView(slug) {
137
137
  */
138
138
  export async function createView(data, userId = null) {
139
139
  const db = await getMetaDb();
140
- const { title, description = '', connection = 'default', pipeline, display, access } = data;
140
+ const { title, description = '', connection = 'default', pipeline, display, access, meta: inputMeta } = data;
141
141
 
142
142
  if (!title) throw new Error('title is required');
143
143
  if (!pipeline?.source) throw new Error('pipeline.source is required');
@@ -170,7 +170,12 @@ export async function createView(data, userId = null) {
170
170
  public: access?.public || false,
171
171
  rowLevel: access?.rowLevel || null
172
172
  },
173
- meta: { createdAt: now, updatedAt: now, createdBy: userId }
173
+ meta: {
174
+ createdAt: now,
175
+ updatedAt: now,
176
+ createdBy: userId,
177
+ ...(inputMeta?.project != null && {project: inputMeta.project})
178
+ }
174
179
  };
175
180
 
176
181
  await db.collection(VIEWS_COLLECTION).insertOne({ ...view });
@@ -194,7 +199,7 @@ export async function updateView(slug, data) {
194
199
  const stages = data.pipeline?.stages ?? existing.pipeline?.stages ?? [];
195
200
  validateStages(stages);
196
201
 
197
- const { _id, id, meta, ...rest } = data;
202
+ const { _id, id, meta: inputMeta, ...rest } = data;
198
203
  const updated = {
199
204
  ...existing,
200
205
  ...rest,
@@ -204,7 +209,13 @@ export async function updateView(slug, data) {
204
209
  ...data.pipeline,
205
210
  stages
206
211
  },
207
- meta: { ...existing.meta, updatedAt: new Date().toISOString() }
212
+ meta: {
213
+ ...existing.meta,
214
+ ...(inputMeta && typeof inputMeta === 'object'
215
+ ? ('project' in inputMeta ? {project: inputMeta.project} : {})
216
+ : {}),
217
+ updatedAt: new Date().toISOString()
218
+ }
208
219
  };
209
220
 
210
221
  await db.collection(VIEWS_COLLECTION).replaceOne({ slug }, { ...updated });
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>{{seoTitle}}</title>
7
7
  <meta name="description" content="{{seoDescription}}">
8
- {{#if ogImage}}<meta property="og:image" content="{{ogImage}}">{{/if}}
8
+ {{seoTags}}
9
9
 
10
10
  <!-- Fonts -->
11
11
  {{#if fontLink}}{{fontLink}}{{/if}}
@@ -103,6 +103,7 @@
103
103
  }
104
104
  window.__CMS_NAV__ = {{navJson}};
105
105
  window.__CMS_SITE__ = {{siteJson}};
106
+ {{footerScript}}
106
107
  (function () {
107
108
  var c = window.__CMS_SITE__ && window.__CMS_SITE__.autoTheme;
108
109
  if (!c || !c.enabled) return;
@@ -115,7 +116,11 @@
115
116
  </script>
116
117
 
117
118
  <!-- Site initialisation -->
118
- <script src="/public/js/site.js?v=20260509-chooser" type="module"></script>
119
+ <script src="/public/js/site.js?v=20260524-formerror" type="module"></script>
120
+
121
+ <!-- Interactive Collection Browser (used by [collection] shortcodes with
122
+ searchable / filterable / sortable / paginate attributes) -->
123
+ <script src="/public/js/collection-browser.js?v=20260523-browser"></script>
119
124
 
120
125
  <!-- Core Forms (logic engine must load before renderer) -->
121
126
  <script src="/public/js/form-logic-engine.js?v=20260509-chooser"></script>
File without changes