domma-cms 0.71.0 → 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.
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/app.js +4 -4
- package/admin/js/lib/activity-throbber.js +1 -0
- package/admin/js/lib/menu-rows.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/settings.html +8 -0
- package/admin/js/views/menu-editor.js +16 -16
- package/admin/js/views/settings.js +3 -3
- package/package.json +2 -2
- package/server/routes/api/auth.js +16 -3
- package/server/routes/api/settings.js +9 -0
- package/server/routes/api/sidebar.js +7 -2
- package/server/server.js +10 -8
- package/server/services/adminHome.js +87 -0
- package/server/services/hooks.js +30 -6
- package/server/services/menus.js +36 -6
- package/server/services/plugins.js +89 -6
- package/server/services/roles.js +14 -3
- package/server/services/sidebar-migration.js +82 -8
- package/admin/js/lib/sidebar-grouping.test.js +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin home - the screen `#/` (Dashboard) opens.
|
|
3
|
+
*
|
|
4
|
+
* Every admin screen is a Tool, and "Dashboard" is simply whichever Tool home
|
|
5
|
+
* points at. Three sources, first answer wins:
|
|
6
|
+
*
|
|
7
|
+
* 1. A confined role's own home (roles.getAdminScope) - it is also the only
|
|
8
|
+
* place a confined user may land.
|
|
9
|
+
* 2. The site's `adminHome` (config/site.json, System > Site Settings).
|
|
10
|
+
* 3. `/` - the built-in dashboard, or the plugin view that replaces it
|
|
11
|
+
* (`views.dashboard`).
|
|
12
|
+
*
|
|
13
|
+
* The site's choice is skipped when the sidebar does not know that screen (a
|
|
14
|
+
* Tool whose plugin has been disabled) or the user lacks the permission guarding
|
|
15
|
+
* it (its sidebar entry's `permission`), so nobody lands on a dead end. The server stays the authority for data either way;
|
|
16
|
+
* this only decides where the admin opens.
|
|
17
|
+
*/
|
|
18
|
+
import {normaliseAdminPath} from './roles.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True if `permissions` grants `resource` - the same test the sidebar uses
|
|
22
|
+
* (sidebar-renderer.js canAny), so home never opens what the sidebar hides.
|
|
23
|
+
*/
|
|
24
|
+
function holds(permissions, resource) {
|
|
25
|
+
if (!resource) return true;
|
|
26
|
+
const p = permissions || [];
|
|
27
|
+
return p.includes(resource) || ['read', 'create', 'update', 'delete'].some(a => p.includes(`${resource}.${a}`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const trimUrl = (url) => (typeof url === 'string' ? url.replace(/\/+$/, '') || '#/' : '');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The path, top down, to the first menu entry whose url is `url` - a Tool
|
|
34
|
+
* placeholder (`ref`) included, since it is where that Tool renders - or null.
|
|
35
|
+
*/
|
|
36
|
+
function pathTo(url, nodes, trail = []) {
|
|
37
|
+
for (const n of nodes || []) {
|
|
38
|
+
if (!n) continue;
|
|
39
|
+
if (trimUrl(n.url) === url) return [...trail, n];
|
|
40
|
+
const hit = pathTo(url, n.items, [...trail, n]);
|
|
41
|
+
if (hit) return hit;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True if the menu hides `text` the older way: a url-less, childless hidden item of that name. */
|
|
47
|
+
function hiddenByName(text, nodes) {
|
|
48
|
+
const t = String(text || '').toLowerCase();
|
|
49
|
+
for (const n of nodes || []) {
|
|
50
|
+
if (!n) continue;
|
|
51
|
+
if (n.hidden && !n.url && !(n.items || []).length && String(n.text || '').toLowerCase() === t) return true;
|
|
52
|
+
if (hiddenByName(text, n.items)) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} o
|
|
59
|
+
* @param {{home: string}|null} o.scope the user's admin scope (confinement)
|
|
60
|
+
* @param {string[]} o.permissions what the user holds
|
|
61
|
+
* @param {unknown} o.siteHome site.json `adminHome`
|
|
62
|
+
* @param {object[]|Function} [o.registered] registered sidebar items (their `.item`s), or a function returning them
|
|
63
|
+
* @param {object[]|Function} [o.menuItems] the admin-sidebar menu's items, or a function returning them
|
|
64
|
+
* @param {(visibility: unknown) => boolean} [o.canSee] the sidebar's visibility test for this user
|
|
65
|
+
* @returns {Promise<string>|string} an admin route path
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveAdminHome({scope, permissions, siteHome, registered = [], menuItems = [], canSee = () => true}) {
|
|
68
|
+
if (scope?.home) return scope.home;
|
|
69
|
+
const home = normaliseAdminPath(siteHome);
|
|
70
|
+
if (!home || home === '/') return '/';
|
|
71
|
+
const url = `#${home}`;
|
|
72
|
+
const list = async (v) => (typeof v === 'function' ? await v() : v) || [];
|
|
73
|
+
// Only a screen the sidebar shows this user: an enabled Tool, or a page in
|
|
74
|
+
// the menu. Where the menu places it decides whether it is shown - a hidden
|
|
75
|
+
// entry, or a hidden or gated folder on the way to it, rules it out even
|
|
76
|
+
// for a registered Tool, exactly as the sidebar leaves it out.
|
|
77
|
+
const tool = (await list(registered)).find(i => i && !i.hidden && trimUrl(i.url) === url);
|
|
78
|
+
const items = await list(menuItems);
|
|
79
|
+
const path = pathTo(url, items);
|
|
80
|
+
if (!tool && (!path || path.at(-1).ref)) return '/'; // nothing enables it
|
|
81
|
+
if (tool && !path && hiddenByName(tool.text, items)) return '/';
|
|
82
|
+
const gates = [...(path || []), ...(tool ? [tool] : [])];
|
|
83
|
+
const open = gates.every(n => !n.hidden
|
|
84
|
+
&& holds(permissions, n.permission)
|
|
85
|
+
&& (n.visibility == null || canSee(n.visibility)));
|
|
86
|
+
return open ? home : '/';
|
|
87
|
+
}
|
package/server/services/hooks.js
CHANGED
|
@@ -71,27 +71,51 @@ export async function registerMenuLocation(slot, def) {
|
|
|
71
71
|
const _registeredSidebarItems = [];
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* Register a sidebar
|
|
75
|
-
*
|
|
76
|
-
*
|
|
74
|
+
* Register a Tool's sidebar entry. Entries are placed into the rendered admin
|
|
75
|
+
* sidebar at render time - they are NOT persisted into menu JSON - so plugins
|
|
76
|
+
* re-register on each server boot.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
78
|
+
* `folder` is the Tool's DEFAULT place: the `key` of an admin-sidebar folder
|
|
79
|
+
* (`'tools'`, `'content'`, `'data'`, `'system'`, ...). It defaults to `'tools'`,
|
|
80
|
+
* and an admin who moves the Tool in the menu overrides it - the renderer
|
|
81
|
+
* places by `url` first (see placeTools in admin/js/lib/sidebar-grouping.js).
|
|
82
|
+
* `parent` is the older form: any node matched by its text.
|
|
83
|
+
*
|
|
84
|
+
* @param {{folder?: string, parent?: string, item: {text: string, url: string, icon?: string, permission?: string, folder?: string, items?: object[]}}} opts
|
|
79
85
|
*/
|
|
80
86
|
export function registerSidebarItem(opts) {
|
|
81
87
|
if (!opts || !opts.item || typeof opts.item.text !== 'string') {
|
|
82
88
|
throw new Error('registerSidebarItem: opts.item.text is required');
|
|
83
89
|
}
|
|
90
|
+
const {folder: itemFolder, ...item} = opts.item;
|
|
84
91
|
_registeredSidebarItems.push({
|
|
85
92
|
parent: opts.parent || null,
|
|
86
|
-
|
|
93
|
+
folder: opts.folder || itemFolder || DEFAULT_TOOL_FOLDER,
|
|
94
|
+
item
|
|
87
95
|
});
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
/** Where a Tool's sidebar entry goes when it names no folder. */
|
|
99
|
+
export const DEFAULT_TOOL_FOLDER = 'tools';
|
|
100
|
+
|
|
101
|
+
/** Sidebar URLs taken off the sidebar - a built-in Tool a plugin has taken over. */
|
|
102
|
+
const _hiddenSidebarUrls = new Set();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Leave the item with this URL out of the sidebar. For a core Tool whose entry
|
|
106
|
+
* is registered before the plugins load, so it cannot simply not be registered.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} url - e.g. '#/contacts'
|
|
109
|
+
*/
|
|
110
|
+
export function hideSidebarUrl(url) {
|
|
111
|
+
_hiddenSidebarUrls.add(url);
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
/**
|
|
91
115
|
* @returns {Array<{parent: string|null, item: object}>}
|
|
92
116
|
*/
|
|
93
117
|
export function getRegisteredSidebarItems() {
|
|
94
|
-
return _registeredSidebarItems.
|
|
118
|
+
return _registeredSidebarItems.filter(entry => !_hiddenSidebarUrls.has(entry?.item?.url));
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
// ---------------------------------------------------------------------------
|
package/server/services/menus.js
CHANGED
|
@@ -714,15 +714,22 @@ export function validateMenu(menu) {
|
|
|
714
714
|
return;
|
|
715
715
|
}
|
|
716
716
|
|
|
717
|
-
|
|
717
|
+
// A Tool placeholder (`ref`, admin-sidebar) takes its label from the
|
|
718
|
+
// Tool's registration unless the admin gave it one of its own.
|
|
719
|
+
if (item.ref === true && item.url && !item.text) {
|
|
720
|
+
// fine - the url is checked below like any leaf
|
|
721
|
+
} else if (!item.text || typeof item.text !== 'string') {
|
|
718
722
|
errors.push(`Item ${path}: text is required`);
|
|
719
723
|
}
|
|
720
724
|
// url is only required for LEAF items (no children). Folder items
|
|
721
725
|
// - those with their own `items` array - are pure groupings and
|
|
722
726
|
// don't need a navigation target. The admin sidebar uses this
|
|
723
727
|
// shape extensively (Content / Data / System headings etc.).
|
|
728
|
+
// A keyed item is an admin-sidebar folder (see sidebar-migration.js)
|
|
729
|
+
// even while empty - the Tools folder is, until a Tool is enabled.
|
|
724
730
|
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
|
725
|
-
|
|
731
|
+
const isKeyedFolder = typeof item.key === 'string' && item.key !== '' && !item.url;
|
|
732
|
+
if (!hasChildren && !isKeyedFolder) {
|
|
726
733
|
if (!item.url || typeof item.url !== 'string' || !URL_PREFIX_RE.test(item.url)) {
|
|
727
734
|
errors.push(`Item ${path}: url must start with /, #, http(s)://, or mailto:`);
|
|
728
735
|
}
|
|
@@ -1082,15 +1089,38 @@ export async function resolveOverlaysForPage(ctx = {}, user = null) {
|
|
|
1082
1089
|
.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
|
|
1083
1090
|
}
|
|
1084
1091
|
|
|
1085
|
-
|
|
1092
|
+
/**
|
|
1093
|
+
* The admin sidebar's stand-in for an item this user may not see: only its
|
|
1094
|
+
* url (or folder key) and `hidden`, so Tool placement still knows the admin put
|
|
1095
|
+
* a Tool there - it must not fall back into Tools for exactly the users it was
|
|
1096
|
+
* restricted from - while the label never reaches them.
|
|
1097
|
+
*/
|
|
1098
|
+
function restrictedStub(item) {
|
|
1099
|
+
const children = Array.isArray(item.items) ? item.items.map(restrictedStub).filter(Boolean) : [];
|
|
1100
|
+
if (!item.url && !item.key && !children.length) return null;
|
|
1101
|
+
return {
|
|
1102
|
+
...(item.url && {url: item.url}),
|
|
1103
|
+
...(item.key && {key: item.key}),
|
|
1104
|
+
hidden: true,
|
|
1105
|
+
// Marks a role gate, as opposed to an admin's hide, for ensureLockedTools.
|
|
1106
|
+
restricted: true,
|
|
1107
|
+
...(children.length && {items: children})
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
export function filterItemsForUser(items, user, {keepHidden = false} = {}) {
|
|
1086
1112
|
const out = [];
|
|
1087
1113
|
for (const item of items) {
|
|
1088
|
-
if (item.hidden) continue;
|
|
1114
|
+
if (item.hidden && !keepHidden) continue;
|
|
1089
1115
|
if (item.visibility != null) {
|
|
1090
|
-
if (!checkVisibility(user, item.visibility))
|
|
1116
|
+
if (!checkVisibility(user, item.visibility)) {
|
|
1117
|
+
const stub = keepHidden ? restrictedStub(item) : null;
|
|
1118
|
+
if (stub) out.push(stub);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1091
1121
|
}
|
|
1092
1122
|
const children = Array.isArray(item.items)
|
|
1093
|
-
? filterItemsForUser(item.items, user)
|
|
1123
|
+
? filterItemsForUser(item.items, user, {keepHidden})
|
|
1094
1124
|
: [];
|
|
1095
1125
|
out.push({...item, items: children});
|
|
1096
1126
|
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
registerMenuLocation,
|
|
21
21
|
registerSanitizeRules,
|
|
22
22
|
registerShortcode,
|
|
23
|
+
hideSidebarUrl,
|
|
23
24
|
registerSidebarItem,
|
|
24
25
|
registerTransform
|
|
25
26
|
} from './hooks.js';
|
|
@@ -138,6 +139,49 @@ const CORE_PLUGINS = new Set([]);
|
|
|
138
139
|
/** Is this plugin a built-in feature rather than an optional add-on? */
|
|
139
140
|
export function isCorePlugin(name) { return CORE_PLUGINS.has(name); }
|
|
140
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The Tools promoted out of plugins/ in 0.67.0 - core code now, each with its
|
|
144
|
+
* own `#/<name>` screen and sidebar entry (server/server.js).
|
|
145
|
+
*
|
|
146
|
+
* Not plugins, so `supersedes` could never reach them the way it reaches the
|
|
147
|
+
* free Blog. A plugin that names one in `supersedes` TAKES IT OVER instead:
|
|
148
|
+
* the core Tool's sidebar entry is hidden and its screen hands over to the
|
|
149
|
+
* plugin's first admin route. The core Tool's API stays up - other plugins
|
|
150
|
+
* (Invoices reads contacts) depend on it, and the paid edition works over the
|
|
151
|
+
* same data - and, like supersession, nothing is written down: a lapsed
|
|
152
|
+
* licence means the plugin does not load and the core Tool is simply back.
|
|
153
|
+
*/
|
|
154
|
+
export const BUILT_IN_TOOLS = new Set(['notes', 'todo', 'analytics', 'contacts']);
|
|
155
|
+
|
|
156
|
+
/** Built-in Tool → {plugin, route}, for the plugins that loaded this boot. */
|
|
157
|
+
const _toolTakeovers = {};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Which built-in Tools a loaded plugin has taken over.
|
|
161
|
+
*
|
|
162
|
+
* @returns {Object.<string, {plugin: string, route: string|null}>}
|
|
163
|
+
*/
|
|
164
|
+
export function getToolTakeovers() {
|
|
165
|
+
return {..._toolTakeovers};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The takeovers one manifest claims: every built-in Tool in its `supersedes`
|
|
170
|
+
* that no earlier plugin has claimed. Pure, for the test.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} manifest
|
|
173
|
+
* @param {object} [taken] - claims already made
|
|
174
|
+
* @returns {Object.<string, {plugin: string, route: string|null}>}
|
|
175
|
+
*/
|
|
176
|
+
export function toolTakeoversOf(manifest, taken = {}) {
|
|
177
|
+
const out = {};
|
|
178
|
+
const route = manifest?.admin?.routes?.[0]?.path ?? null;
|
|
179
|
+
for (const tool of Array.isArray(manifest?.supersedes) ? manifest.supersedes : []) {
|
|
180
|
+
if (BUILT_IN_TOOLS.has(tool) && !taken[tool] && !out[tool]) out[tool] = {plugin: manifest.name, route};
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
141
185
|
/**
|
|
142
186
|
* Scan the plugins/ directory and return all valid manifests.
|
|
143
187
|
* Validates mandatory fields and required files (plugin.js, config.js).
|
|
@@ -417,23 +461,34 @@ export async function registerPlugins(fastify) {
|
|
|
417
461
|
await fastify.register(plugin, {
|
|
418
462
|
prefix,
|
|
419
463
|
auth: {authenticate, requireRole, requireAdmin, requireVisibility, requirePermission},
|
|
420
|
-
hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation,
|
|
464
|
+
hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation,
|
|
465
|
+
// Stamped with the plugin's name, so the Menus editor can say where a Tool came from.
|
|
466
|
+
registerSidebarItem: (opts) => registerSidebarItem({...opts, item: {source: manifest.displayName || manifest.name, ...opts?.item}}),
|
|
467
|
+
on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name)},
|
|
421
468
|
settings,
|
|
422
469
|
config: {}
|
|
423
470
|
});
|
|
424
471
|
loaded.push(manifest.name);
|
|
425
472
|
|
|
473
|
+
// A built-in Tool this plugin replaces: hide the core sidebar entry
|
|
474
|
+
// (the admin sends its screen to the plugin - getAdminPluginConfig).
|
|
475
|
+
for (const [tool, claim] of Object.entries(toolTakeoversOf(manifest, _toolTakeovers))) {
|
|
476
|
+
_toolTakeovers[tool] = claim;
|
|
477
|
+
hideSidebarUrl(`#/${tool}`);
|
|
478
|
+
fastify.log.info(`[plugins] built-in Tool "${tool}" taken over by "${manifest.name}".`);
|
|
479
|
+
}
|
|
480
|
+
|
|
426
481
|
// Bridge plugin.json `admin.sidebar` entries into the sidebar
|
|
427
482
|
// registry so they render in the admin sidebar. The admin renderer
|
|
428
483
|
// reads plugin nav from /api/sidebar/registered-items; plugins
|
|
429
484
|
// declare their nav in the manifest rather than calling
|
|
430
|
-
// registerSidebarItem, so register it here on their behalf.
|
|
431
|
-
//
|
|
432
|
-
// can
|
|
485
|
+
// registerSidebarItem, so register it here on their behalf. An
|
|
486
|
+
// entry's `folder` is its default place (Tools when it names none);
|
|
487
|
+
// an admin can move it anywhere in the menu.
|
|
433
488
|
const isCore = CORE_PLUGINS.has(manifest.name);
|
|
434
489
|
for (const item of manifest.admin?.sidebar || []) {
|
|
435
490
|
try {
|
|
436
|
-
registerSidebarItem({item: {...item, core: isCore}});
|
|
491
|
+
registerSidebarItem({item: {source: manifest.displayName || manifest.name, ...item, core: isCore}});
|
|
437
492
|
} catch (err) {
|
|
438
493
|
fastify.log.warn(`[plugins] sidebar item for "${manifest.name}" skipped: ${err.message}`);
|
|
439
494
|
}
|
|
@@ -1113,6 +1168,33 @@ export function pluginExpiry(manifest, given) {
|
|
|
1113
1168
|
: null;
|
|
1114
1169
|
}
|
|
1115
1170
|
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* The sidebar name of the screen a view draws, for its banner - '' when the
|
|
1174
|
+
* plugin has one screen or the view has no sidebar entry of its own.
|
|
1175
|
+
*
|
|
1176
|
+
* A route maps a path to a view and a sidebar entry links to a path, so the
|
|
1177
|
+
* entry whose link is one of the view's routes names it. Sub-entries win over
|
|
1178
|
+
* the top-level one: Site Manager's top entry and its "Sites" entry share a
|
|
1179
|
+
* link, and "Sites" is the screen.
|
|
1180
|
+
*
|
|
1181
|
+
* @param {object} manifest
|
|
1182
|
+
* @param {string} viewName
|
|
1183
|
+
* @returns {string}
|
|
1184
|
+
*/
|
|
1185
|
+
export function viewSection(manifest, viewName) {
|
|
1186
|
+
const admin = manifest.admin || {};
|
|
1187
|
+
const paths = new Set((admin.routes || [])
|
|
1188
|
+
.filter(r => r && r.view === viewName && typeof r.path === 'string')
|
|
1189
|
+
.map(r => `#${r.path}`));
|
|
1190
|
+
if (!paths.size) return '';
|
|
1191
|
+
const top = admin.sidebar || [];
|
|
1192
|
+
const entries = [...top.flatMap(e => (e && e.items) || []), ...top];
|
|
1193
|
+
const hit = entries.find(e => e && paths.has(e.url));
|
|
1194
|
+
const text = hit ? String(hit.text || '').trim() : '';
|
|
1195
|
+
return text && text !== (manifest.displayName || manifest.name) ? text : '';
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1116
1198
|
export async function getAdminPluginConfig() {
|
|
1117
1199
|
const manifests = await discoverPlugins();
|
|
1118
1200
|
const states = getPluginStates();
|
|
@@ -1160,6 +1242,7 @@ export async function getAdminPluginConfig() {
|
|
|
1160
1242
|
meta[viewName] = {
|
|
1161
1243
|
plugin: manifest.name,
|
|
1162
1244
|
displayName: manifest.displayName || manifest.name,
|
|
1245
|
+
section: viewSection(manifest, viewName),
|
|
1163
1246
|
version: manifest.version || '1.0.0',
|
|
1164
1247
|
date: manifest.date || '',
|
|
1165
1248
|
author: manifest.author || '',
|
|
@@ -1189,5 +1272,5 @@ export async function getAdminPluginConfig() {
|
|
|
1189
1272
|
}
|
|
1190
1273
|
}
|
|
1191
1274
|
|
|
1192
|
-
return { sidebar, routes, views, css, meta };
|
|
1275
|
+
return { sidebar, routes, views, css, meta, takeovers: getToolTakeovers() };
|
|
1193
1276
|
}
|
package/server/services/roles.js
CHANGED
|
@@ -337,11 +337,22 @@ const ADMIN_PATH_RE = /^\/[A-Za-z0-9\-_/]*$/;
|
|
|
337
337
|
* @param {*} raw
|
|
338
338
|
* @returns {{home: string, allow: string[]}|null}
|
|
339
339
|
*/
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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()))
|
|
343
349
|
? (p.trim().replace(/\/+$/, '') || '/')
|
|
344
350
|
: null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function normaliseAdminScope(raw) {
|
|
354
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
355
|
+
const clean = normaliseAdminPath;
|
|
345
356
|
const allow = [...new Set((Array.isArray(raw.allow) ? raw.allow : []).map(clean).filter(Boolean))];
|
|
346
357
|
const home = clean(raw.home) || allow[0] || null;
|
|
347
358
|
if (!home) return null;
|
|
@@ -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,
|
|
7
|
-
* and patches config/menu-locations.json to map the
|
|
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"])});
|