domma-cms 0.70.1 → 0.72.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 (48) hide show
  1. package/CLAUDE.md +24 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/app.js +5 -5
  6. package/admin/js/lib/activity-throbber.js +1 -0
  7. package/admin/js/lib/admin-scope.js +1 -0
  8. package/admin/js/lib/menu-rows.js +1 -0
  9. package/admin/js/lib/plugin-chrome.js +1 -1
  10. package/admin/js/lib/sidebar-grouping.js +1 -1
  11. package/admin/js/lib/sidebar-renderer.js +4 -4
  12. package/admin/js/templates/page-editor.html +1 -4
  13. package/admin/js/templates/role-editor.html +28 -0
  14. package/admin/js/templates/settings.html +8 -0
  15. package/admin/js/views/menu-editor.js +16 -16
  16. package/admin/js/views/page-editor.js +26 -26
  17. package/admin/js/views/role-editor.js +3 -1
  18. package/admin/js/views/settings.js +3 -3
  19. package/package.json +2 -2
  20. package/plugins/_template/plugin.js +5 -2
  21. package/plugins/blog/CLAUDE.md +20 -5
  22. package/plugins/blog/admin/views/kit.js +8 -10
  23. package/plugins/blog/lib/layouts.js +36 -0
  24. package/plugins/blog/lib/nav-link.js +22 -0
  25. package/plugins/blog/lib/page.js +13 -1
  26. package/plugins/blog/plugin.js +28 -9
  27. package/plugins/blog/plugin.json +6 -6
  28. package/plugins/blog/plugin.public.js +102 -80
  29. package/plugins/blog/tests/layouts.test.js +29 -0
  30. package/plugins/blog/tests/nav-link.test.js +10 -1
  31. package/plugins/blog/tests/public.test.js +21 -0
  32. package/plugins/free-tier.lock.json +13 -13
  33. package/server/middleware/auth.js +21 -0
  34. package/server/routes/api/auth.js +29 -8
  35. package/server/routes/api/collections.js +40 -3
  36. package/server/routes/api/settings.js +9 -0
  37. package/server/routes/api/sidebar.js +31 -10
  38. package/server/routes/public.js +13 -17
  39. package/server/server.js +10 -8
  40. package/server/services/adminHome.js +87 -0
  41. package/server/services/hooks.js +30 -6
  42. package/server/services/menus.js +36 -6
  43. package/server/services/permissionRegistry.js +10 -7
  44. package/server/services/pluginInstaller.js +23 -9
  45. package/server/services/plugins.js +333 -45
  46. package/server/services/roles.js +181 -5
  47. package/server/services/sidebar-migration.js +82 -8
  48. package/admin/js/lib/sidebar-grouping.test.js +0 -1
@@ -4,7 +4,9 @@
4
4
  * Auth middleware calls getRoleMap() / getPermissionsFor() at request time.
5
5
  *
6
6
  * Base roles: super-admin (0), admin (1), user (2).
7
- * Plugin-contributed roles are tagged with a `plugin` field and removed on plugin teardown.
7
+ * Plugin-contributed roles are tagged with a `plugin` field. They are created
8
+ * once (`ensureRole`) and then belong to the admin; disabling the plugin leaves
9
+ * them in place so user assignments survive a disable/enable cycle.
8
10
  */
9
11
  import fs from 'fs/promises';
10
12
  import path from 'path';
@@ -100,6 +102,9 @@ let permissionsMap = new Map();
100
102
  /** @type {Map<string,string[]>} role name → raw permissions array */
101
103
  let rawPermissionsMap = new Map();
102
104
 
105
+ /** @type {string[]} names of level-0 roles - they hold every permission, including plugin ones */
106
+ let rootRoles = [];
107
+
103
108
  /**
104
109
  * Build in-memory maps from an array of data entries.
105
110
  * Supports both bare resource names ('pages') and dotted action strings ('pages.read').
@@ -111,6 +116,7 @@ function buildCache(entries) {
111
116
  roleMap = new Map();
112
117
  permissionsMap = new Map();
113
118
  rawPermissionsMap = new Map();
119
+ rootRoles = entries.filter(e => e.data?.level === 0).map(e => e.data.name);
114
120
 
115
121
  const addTo = (key, role) => {
116
122
  if (!permissionsMap.has(key)) permissionsMap.set(key, []);
@@ -119,11 +125,13 @@ function buildCache(entries) {
119
125
 
120
126
  for (const entry of entries) {
121
127
  const d = entry.data;
128
+ const adminScope = d.level === 0 ? null : normaliseAdminScope(d.admin);
122
129
  roleMap.set(d.name, {
123
130
  label: d.label,
124
131
  level: d.level,
125
132
  badgeClass: d.badgeClass || '',
126
- ...(d.meta != null && {meta: d.meta})
133
+ ...(d.meta != null && {meta: d.meta}),
134
+ ...(adminScope && {admin: adminScope})
127
135
  });
128
136
  rawPermissionsMap.set(d.name, d.permissions || []);
129
137
  for (const perm of (d.permissions || [])) {
@@ -312,6 +320,152 @@ export async function createRole(data) {
312
320
  buildCache(entries);
313
321
  }
314
322
 
323
+ /** An admin route: `/`-rooted, hash-router path characters only. */
324
+ const ADMIN_PATH_RE = /^\/[A-Za-z0-9\-_/]*$/;
325
+
326
+ /**
327
+ * Validate a role's `admin` block - which admin screens the role is confined
328
+ * to - and return it in canonical form, or null if it is absent or unusable.
329
+ *
330
+ * {"home": "/portal", "allow": ["/portal"]}
331
+ *
332
+ * `allow` lists route prefixes (`/portal` also admits `/portal/orders`).
333
+ * `home` is where a user is sent from anywhere else; it defaults to the first
334
+ * allowed route and is always itself allowed. A block with no usable route is
335
+ * treated as absent - confining someone to nothing would lock them out.
336
+ *
337
+ * @param {*} raw
338
+ * @returns {{home: string, allow: string[]}|null}
339
+ */
340
+ /**
341
+ * An admin route path (`/portal`, `/plugins/x/dashboard`), cleaned - or null if
342
+ * it is not one. Trailing slashes go; the root stays `/`.
343
+ *
344
+ * @param {unknown} p
345
+ * @returns {string|null}
346
+ */
347
+ export function normaliseAdminPath(p) {
348
+ return (typeof p === 'string' && ADMIN_PATH_RE.test(p.trim()))
349
+ ? (p.trim().replace(/\/+$/, '') || '/')
350
+ : null;
351
+ }
352
+
353
+ export function normaliseAdminScope(raw) {
354
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
355
+ const clean = normaliseAdminPath;
356
+ const allow = [...new Set((Array.isArray(raw.allow) ? raw.allow : []).map(clean).filter(Boolean))];
357
+ const home = clean(raw.home) || allow[0] || null;
358
+ if (!home) return null;
359
+ if (!allow.includes(home)) allow.unshift(home);
360
+ return {home, allow};
361
+ }
362
+
363
+ /**
364
+ * The admin scope for a user holding `roleNames`, or null for unrestricted.
365
+ *
366
+ * A user is confined only when EVERY role they hold is: one unscoped role
367
+ * (an `admin` additional role, say) lifts the restriction, because roles
368
+ * grant, they never take away. The level-0 role is never confined. When
369
+ * confined, the allowed routes are the union across roles and `home` is the
370
+ * first role's (the primary role comes first).
371
+ *
372
+ * @param {string[]} roleNames
373
+ * @returns {{home: string, allow: string[]}|null}
374
+ */
375
+ export function getAdminScope(roleNames) {
376
+ const scopes = [];
377
+ for (const name of roleNames || []) {
378
+ const role = roleMap.get(name);
379
+ if (!role) continue; // an unknown role grants nothing either way
380
+ if (role.level === 0 || !role.admin) return null;
381
+ scopes.push(role.admin);
382
+ }
383
+ if (!scopes.length) return null;
384
+ return {
385
+ home: scopes[0].home,
386
+ allow: [...new Set(scopes.flatMap(sc => sc.allow))]
387
+ };
388
+ }
389
+
390
+ /**
391
+ * A role's stored data (as saved, including `plugin` and `grantedBy`), or null.
392
+ *
393
+ * @param {string} name
394
+ * @returns {Promise<object|null>}
395
+ */
396
+ export async function getRoleData(name) {
397
+ try {
398
+ return (await readData()).find(e => e.data?.name === name)?.data ?? null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Create a role only if no role of that name exists yet.
406
+ *
407
+ * This is what plugins use on every enable and every boot. A role the admin
408
+ * has since edited (label, level, permissions) is left exactly as they left
409
+ * it - the plugin file is the starting point, not a setting that wins.
410
+ *
411
+ * @param {{name:string,label:string,level:number,permissions?:string[],badgeClass?:string,plugin?:string}} data
412
+ * @returns {Promise<boolean>} true if the role was created
413
+ */
414
+ export async function ensureRole(data) {
415
+ let entries;
416
+ try {
417
+ entries = await readData();
418
+ } catch {
419
+ entries = [];
420
+ }
421
+ if (entries.some(e => e.data?.name === data.name)) return false;
422
+ entries.push(makeEntry({permissions: [], ...data}));
423
+ await writeData(entries);
424
+ buildCache(entries);
425
+ return true;
426
+ }
427
+
428
+ /**
429
+ * Grant a permission to an existing role ONCE per source.
430
+ *
431
+ * A plugin that wants the site's own roles (say `admin`) to be able to use
432
+ * its screens calls this for each. The grant is recorded on the role under
433
+ * `grantedBy`, so if the admin later removes the permission it stays
434
+ * removed - the plugin does not re-add it on the next boot.
435
+ *
436
+ * @param {string} roleName
437
+ * @param {string} permission - `resource` or `resource.action`
438
+ * @param {string} source - usually the plugin name
439
+ * @returns {Promise<boolean>} true if the role changed
440
+ */
441
+ export async function grantPermissionOnce(roleName, permission, source) {
442
+ let entries;
443
+ try {
444
+ entries = await readData();
445
+ } catch {
446
+ return false;
447
+ }
448
+ const entry = entries.find(e => e.data?.name === roleName);
449
+ if (!entry) return false;
450
+
451
+ const marker = `${source}:${permission}`;
452
+ const grantedBy = Array.isArray(entry.data.grantedBy) ? entry.data.grantedBy : [];
453
+ if (grantedBy.includes(marker)) return false;
454
+
455
+ const perms = entry.data.permissions || [];
456
+ const [resource] = permission.split('.');
457
+ const alreadyHeld = perms.includes(permission) || perms.includes(resource);
458
+ entry.data = {
459
+ ...entry.data,
460
+ permissions: alreadyHeld ? perms : [...perms, permission],
461
+ grantedBy: [...grantedBy, marker]
462
+ };
463
+ entry.updatedAt = new Date().toISOString();
464
+ await writeData(entries);
465
+ buildCache(entries);
466
+ return true;
467
+ }
468
+
315
469
  /**
316
470
  * Remove a role entry by name and rebuild the cache.
317
471
  * Refuses to remove base roles (level 0 protection is enforced elsewhere;
@@ -383,9 +537,31 @@ export function getRoleLevel(roleName) {
383
537
  * @returns {string[]}
384
538
  */
385
539
  export function getPermissionsFor(resource, action) {
386
- if (action) return permissionsMap.get(`${resource}.${action}`) ?? [];
387
- if (resource.includes('.')) return permissionsMap.get(resource) ?? [];
388
- return permissionsMap.get(resource) ?? [];
540
+ const key = action ? `${resource}.${action}` : resource;
541
+ const granted = permissionsMap.get(key) ?? [];
542
+ // The root role is seeded with the base registry only, so a resource a
543
+ // plugin registers later would otherwise lock out the one role that is
544
+ // meant to be able to do everything.
545
+ const missingRoot = rootRoles.filter(r => !granted.includes(r));
546
+ return missingRoot.length ? [...granted, ...missingRoot] : granted;
547
+ }
548
+
549
+ /**
550
+ * The permissions a set of roles holds between them - the union of each
551
+ * role's raw list, so a user with additional roles sees everything any of
552
+ * them grants. A level-0 role holds every resource in the effective registry.
553
+ *
554
+ * @param {string[]} roleNames
555
+ * @param {string[]} [allResources] - effective registry keys, for the root role
556
+ * @returns {string[]}
557
+ */
558
+ export function getPermissionsForRoles(roleNames, allResources = RESOURCES) {
559
+ const out = new Set();
560
+ for (const name of roleNames || []) {
561
+ if (rootRoles.includes(name)) allResources.forEach(r => out.add(r));
562
+ for (const perm of rawPermissionsMap.get(name) ?? []) out.add(perm);
563
+ }
564
+ return [...out];
389
565
  }
390
566
 
391
567
  /**
@@ -3,8 +3,13 @@
3
3
  *
4
4
  * Runs once on server boot. Idempotent - guarded by the existence of
5
5
  * config/menus/admin-sidebar.json. Seeds the admin sidebar menu with the
6
- * standard tree (Overview, Projects, Content, Data, System, Documentation)
7
- * and patches config/menu-locations.json to map the `admin-sidebar` slot.
6
+ * standard tree (Overview, Projects, Content, Data, Tools, System,
7
+ * Documentation) and patches config/menu-locations.json to map the
8
+ * `admin-sidebar` slot.
9
+ *
10
+ * Top-level folders carry a stable `key`. A Tool names its default folder by
11
+ * key, so an admin can rename a folder without the Tools that live in it
12
+ * falling out of it. ensureFolderKeys() brings menus seeded before keys up.
8
13
  */
9
14
  import fs from 'fs/promises';
10
15
  import path from 'path';
@@ -16,19 +21,19 @@ const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'c
16
21
 
17
22
  const SEED_ITEMS = [
18
23
  {
19
- text: 'Overview', icon: 'home',
24
+ text: 'Overview', key: 'overview', icon: 'home',
20
25
  items: [
21
26
  {text: 'Dashboard', url: '#/', icon: 'home'}
22
27
  ]
23
28
  },
24
29
  {
25
- text: 'Projects', icon: 'folder', permission: 'projects',
30
+ text: 'Projects', key: 'projects', icon: 'folder', permission: 'projects',
26
31
  items: [
27
32
  {text: 'Manage projects', url: '#/projects', icon: 'folder', permission: 'projects'}
28
33
  ]
29
34
  },
30
35
  {
31
- text: 'Content', icon: 'edit',
36
+ text: 'Content', key: 'content', icon: 'edit',
32
37
  items: [
33
38
  {text: 'Pages', url: '#/pages', icon: 'file-text', permission: 'pages'},
34
39
  {text: 'Media', url: '#/media', icon: 'image', permission: 'media'},
@@ -36,7 +41,7 @@ const SEED_ITEMS = [
36
41
  ]
37
42
  },
38
43
  {
39
- text: 'Data', icon: 'database',
44
+ text: 'Data', key: 'data', icon: 'database',
40
45
  items: [
41
46
  {text: 'Collections', url: '#/collections', icon: 'database', permission: 'collections'},
42
47
  {text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections'},
@@ -47,8 +52,12 @@ const SEED_ITEMS = [
47
52
  {text: 'Components', url: '#/components', icon: 'component', permission: 'components'}
48
53
  ]
49
54
  },
55
+ // Enabled Tools are placed here at render time (placeTools in
56
+ // admin/js/lib/sidebar-grouping.js); the folder is persisted so an admin
57
+ // can rename, move or reorder it like any other.
58
+ {text: 'Tools', key: 'tools', icon: 'tool', items: []},
50
59
  {
51
- text: 'System', icon: 'settings',
60
+ text: 'System', key: 'system', icon: 'settings',
52
61
  items: [
53
62
  {text: 'Notifications', url: '#/system/notifications', icon: 'bell', permission: 'notifications'},
54
63
  {text: 'Roles', url: '#/roles', icon: 'shield', permission: 'plugins'},
@@ -63,7 +72,7 @@ const SEED_ITEMS = [
63
72
  ]
64
73
  },
65
74
  {
66
- text: 'Documentation', icon: 'book',
75
+ text: 'Documentation', key: 'documentation', icon: 'book',
67
76
  items: [
68
77
  DOC_USAGE_SUBMENU(),
69
78
  DOC_TUTORIALS_SUBMENU(),
@@ -307,3 +316,68 @@ export async function ensureDocumentationSubmenus(opts = {}) {
307
316
  console.log('[admin-sidebar] Upgraded Documentation group with per-topic submenus');
308
317
  return {updated: true};
309
318
  }
319
+
320
+ /** Keys for the folders the seed has always created, matched by their original name. */
321
+ const SEED_FOLDER_KEYS = {
322
+ overview: 'overview', projects: 'projects', content: 'content', data: 'data',
323
+ tools: 'tools', system: 'system', documentation: 'documentation'
324
+ };
325
+
326
+ /**
327
+ * Give an existing install's top-level folders their stable `key`, and - once -
328
+ * a persisted Tools folder for enabled Tools to be placed into.
329
+ *
330
+ * - A folder is keyed only if it has no key yet and still carries one of the
331
+ * seed's names; a folder the admin made, or renamed before keys existed, is
332
+ * left alone (placement falls back to Tools for it).
333
+ * - The Tools folder is added only on the first run (`meta.toolsFolder`), so
334
+ * an admin who later deletes it does not get it back on every boot. Until
335
+ * then placement synthesises one, exactly as before.
336
+ *
337
+ * Returns `{updated: boolean, reason?: string}`.
338
+ *
339
+ * @param {{configDir?: string}} [opts]
340
+ */
341
+ export async function ensureFolderKeys(opts = {}) {
342
+ const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
343
+ const menuPath = path.join(configDir, 'menus', 'admin-sidebar.json');
344
+
345
+ if (!await exists(menuPath)) {
346
+ return {updated: false, reason: 'admin-sidebar.json not present'};
347
+ }
348
+
349
+ const menu = await readJson(menuPath);
350
+ const items = Array.isArray(menu.items) ? menu.items : (menu.items = []);
351
+ const isFolder = (n) => n && typeof n === 'object' && !n.url && n.type !== 'separator' && n.type !== 'spacer';
352
+
353
+ let changed = false;
354
+ const used = new Set(items.filter(n => isFolder(n) && n.key).map(n => n.key));
355
+ for (const node of items) {
356
+ if (!isFolder(node) || node.key) continue;
357
+ const key = SEED_FOLDER_KEYS[String(node.text || '').trim().toLowerCase()];
358
+ if (!key || used.has(key)) continue;
359
+ node.key = key;
360
+ used.add(key);
361
+ changed = true;
362
+ }
363
+
364
+ // An empty menu is left empty: the renderer's minimal fallback (Dashboard,
365
+ // Menus) is what an admin needs to repair it, and a lone Tools folder would
366
+ // stop that fallback from applying.
367
+ if (!menu.meta?.toolsFolder && items.length) {
368
+ if (!used.has('tools')) {
369
+ const idx = items.findIndex(n => isFolder(n) && ['system', 'documentation'].includes(n.key));
370
+ const tools = {text: 'Tools', key: 'tools', icon: 'tool', items: []};
371
+ items.splice(idx === -1 ? items.length : idx, 0, tools);
372
+ }
373
+ menu.meta = {...(menu.meta || {}), toolsFolder: true};
374
+ changed = true;
375
+ }
376
+
377
+ if (!changed) return {updated: false, reason: 'already up to date'};
378
+
379
+ menu.meta = {...(menu.meta || {}), updatedAt: new Date().toISOString()};
380
+ await writeJson(menuPath, menu);
381
+ console.log('[admin-sidebar] Gave the sidebar folders stable keys');
382
+ return {updated: true};
383
+ }
@@ -1 +0,0 @@
1
- import{test as o}from"node:test";import s from"node:assert/strict";import{groupPluginItems as a,stripItemByUrl as p,insertFoldersBeforeSystem as u,pruneEmptySynthesisedFolders as i,TOOLS_FOLDER_TEXT as m,MANAGE_PLUGINS_URL as d}from"./sidebar-grouping.js";const n=(e,t=null)=>({parent:t,item:e});o("groupPluginItems routes every enabled item to Tools, whatever its core flag",()=>{const{toolsFolder:e}=a([n({text:"Analytics",url:"#/plugins/analytics",core:!0}),n({text:"Todo",url:"#/plugins/todo",core:!1}),n({text:"Notes",url:"#/plugins/notes"})]);s.equal(e.text,m),s.deepEqual(e.items.map(t=>t.text),["Analytics","Todo","Notes"])}),o("Marketplace is a standalone entry, not a folder",()=>{const{managePluginsItem:e}=a([n({text:"Todo",url:"#/plugins/todo"})]);s.equal(e.text,"Marketplace"),s.equal(e.url,d),s.equal(e.permission,"plugins"),s.ok(!("items"in e),"it must not carry a sub-tree")}),o("groupPluginItems omits the Tools folder only when nothing is enabled",()=>{const e=a([n({text:"Todo",url:"#/plugins/todo"})]);s.deepEqual(e.toolsFolder.items.map(l=>l.text),["Todo"]);const t=a([]);s.equal(t.toolsFolder,null),s.equal(t.managePluginsItem.text,"Marketplace")}),o("groupPluginItems keeps explicitly-parented items separate",()=>{const{parented:e,toolsFolder:t}=a([n({text:"Nested",url:"#/x"},"Content"),n({text:"Todo",url:"#/plugins/todo",core:!1})]);s.equal(e.length,1),s.equal(e[0].parent,"Content"),s.deepEqual(t.items.map(l=>l.text),["Todo"])}),o("each call returns its own Marketplace object",()=>{const e=a([]).managePluginsItem,t=a([]).managePluginsItem;s.notEqual(e,t),s.deepEqual(e,t)}),o("stripItemByUrl removes the management link at any depth",()=>{const e=[{text:"Overview",items:[{text:"Dashboard",url:"#/"}]},{text:"System",items:[{text:"Users",url:"#/users"},{text:"Plugins",url:"#/plugins"}]}],l=p(e,"#/plugins").find(r=>r.text==="System");s.deepEqual(l.items.map(r=>r.text),["Users"]),s.equal(e.find(r=>r.text==="System").items.length,2)}),o("insertFoldersBeforeSystem places Tools then Marketplace before System",()=>{const t=u([{text:"Overview"},{text:"Data"},{text:"System"},{text:"Documentation"}],[{text:"Tools",items:[]},{text:"Marketplace",url:"#/plugins"},null]);s.deepEqual(t.map(l=>l.text),["Overview","Data","Tools","Marketplace","System","Documentation"])}),o("insertFoldersBeforeSystem appends when no System/Documentation anchor",()=>{const t=u([{text:"Overview"},{text:"Data"}],[{text:"Marketplace",url:"#/plugins"}]);s.deepEqual(t.map(l=>l.text),["Overview","Data","Marketplace"])}),o("pruneEmptySynthesisedFolders drops an empty Tools folder but keeps built-ins",()=>{const e=[{text:"Overview",items:[]},{text:"Tools",items:[]}];s.deepEqual(i(e).map(t=>t.text),["Overview"])}),o("pruneEmptySynthesisedFolders leaves the standalone Marketplace alone",()=>{const e=[{text:"Tools",items:[{text:"Analytics"}]},{text:"Marketplace",url:"#/plugins"}];s.deepEqual(i(e).map(t=>t.text),["Tools","Marketplace"])});