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.
- package/CLAUDE.md +24 -3
- 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 +5 -5
- package/admin/js/lib/activity-throbber.js +1 -0
- package/admin/js/lib/admin-scope.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/page-editor.html +1 -4
- package/admin/js/templates/role-editor.html +28 -0
- package/admin/js/templates/settings.html +8 -0
- package/admin/js/views/menu-editor.js +16 -16
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/role-editor.js +3 -1
- package/admin/js/views/settings.js +3 -3
- package/package.json +2 -2
- package/plugins/_template/plugin.js +5 -2
- package/plugins/blog/CLAUDE.md +20 -5
- package/plugins/blog/admin/views/kit.js +8 -10
- package/plugins/blog/lib/layouts.js +36 -0
- package/plugins/blog/lib/nav-link.js +22 -0
- package/plugins/blog/lib/page.js +13 -1
- package/plugins/blog/plugin.js +28 -9
- package/plugins/blog/plugin.json +6 -6
- package/plugins/blog/plugin.public.js +102 -80
- package/plugins/blog/tests/layouts.test.js +29 -0
- package/plugins/blog/tests/nav-link.test.js +10 -1
- package/plugins/blog/tests/public.test.js +21 -0
- package/plugins/free-tier.lock.json +13 -13
- package/server/middleware/auth.js +21 -0
- package/server/routes/api/auth.js +29 -8
- package/server/routes/api/collections.js +40 -3
- package/server/routes/api/settings.js +9 -0
- package/server/routes/api/sidebar.js +31 -10
- package/server/routes/public.js +13 -17
- 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/permissionRegistry.js +10 -7
- package/server/services/pluginInstaller.js +23 -9
- package/server/services/plugins.js +333 -45
- package/server/services/roles.js +181 -5
- package/server/services/sidebar-migration.js +82 -8
- package/admin/js/lib/sidebar-grouping.test.js +0 -1
|
@@ -14,18 +14,22 @@ import fs from 'fs/promises';
|
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import matter from 'gray-matter';
|
|
16
16
|
import {getConfig, saveConfig} from '../config.js';
|
|
17
|
-
import {authenticate, requireAdmin, requireRole, requireVisibility} from '../middleware/auth.js';
|
|
17
|
+
import {authenticate, requireAdmin, requirePermission, requireRole, requireVisibility} from '../middleware/auth.js';
|
|
18
18
|
import {
|
|
19
19
|
hooks,
|
|
20
20
|
registerMenuLocation,
|
|
21
21
|
registerSanitizeRules,
|
|
22
22
|
registerShortcode,
|
|
23
|
+
hideSidebarUrl,
|
|
23
24
|
registerSidebarItem,
|
|
24
25
|
registerTransform
|
|
25
26
|
} from './hooks.js';
|
|
26
27
|
import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permissionRegistry.js';
|
|
27
28
|
import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
|
|
28
29
|
import {createCollection, getCollection} from './collections.js';
|
|
30
|
+
import * as defaultRolesService from './roles.js';
|
|
31
|
+
import {normaliseAdminScope} from './roles.js';
|
|
32
|
+
import * as defaultUsersService from './users.js';
|
|
29
33
|
|
|
30
34
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
31
35
|
|
|
@@ -135,6 +139,49 @@ const CORE_PLUGINS = new Set([]);
|
|
|
135
139
|
/** Is this plugin a built-in feature rather than an optional add-on? */
|
|
136
140
|
export function isCorePlugin(name) { return CORE_PLUGINS.has(name); }
|
|
137
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
|
+
|
|
138
185
|
/**
|
|
139
186
|
* Scan the plugins/ directory and return all valid manifests.
|
|
140
187
|
* Validates mandatory fields and required files (plugin.js, config.js).
|
|
@@ -413,24 +460,35 @@ export async function registerPlugins(fastify) {
|
|
|
413
460
|
const prefix = `/api/plugins/${manifest.name}`;
|
|
414
461
|
await fastify.register(plugin, {
|
|
415
462
|
prefix,
|
|
416
|
-
auth: {authenticate, requireRole, requireAdmin, requireVisibility},
|
|
417
|
-
hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation,
|
|
463
|
+
auth: {authenticate, requireRole, requireAdmin, requireVisibility, requirePermission},
|
|
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)},
|
|
418
468
|
settings,
|
|
419
469
|
config: {}
|
|
420
470
|
});
|
|
421
471
|
loaded.push(manifest.name);
|
|
422
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
|
+
|
|
423
481
|
// Bridge plugin.json `admin.sidebar` entries into the sidebar
|
|
424
482
|
// registry so they render in the admin sidebar. The admin renderer
|
|
425
483
|
// reads plugin nav from /api/sidebar/registered-items; plugins
|
|
426
484
|
// declare their nav in the manifest rather than calling
|
|
427
|
-
// registerSidebarItem, so register it here on their behalf.
|
|
428
|
-
//
|
|
429
|
-
// 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.
|
|
430
488
|
const isCore = CORE_PLUGINS.has(manifest.name);
|
|
431
489
|
for (const item of manifest.admin?.sidebar || []) {
|
|
432
490
|
try {
|
|
433
|
-
registerSidebarItem({item: {...item, core: isCore}});
|
|
491
|
+
registerSidebarItem({item: {source: manifest.displayName || manifest.name, ...item, core: isCore}});
|
|
434
492
|
} catch (err) {
|
|
435
493
|
fastify.log.warn(`[plugins] sidebar item for "${manifest.name}" skipped: ${err.message}`);
|
|
436
494
|
}
|
|
@@ -440,6 +498,12 @@ export async function registerPlugins(fastify) {
|
|
|
440
498
|
await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
|
|
441
499
|
.catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
|
|
442
500
|
|
|
501
|
+
// Permissions are held in memory, so they are registered on every
|
|
502
|
+
// boot, not only on enable (roles are created once, then kept).
|
|
503
|
+
await import('./roles.js')
|
|
504
|
+
.then(rolesService => registerPluginAccess(manifest.name, rolesService))
|
|
505
|
+
.catch(err => fastify.log.warn(`[plugins] Access setup for "${manifest.name}" failed: ${err.message}`));
|
|
506
|
+
|
|
443
507
|
// Detect optional plugin.public.js for root-level route registration
|
|
444
508
|
_loadedPlugins[manifest.name] = { enabled: true, publicEntry: null };
|
|
445
509
|
const publicEntryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.public.js');
|
|
@@ -634,38 +698,240 @@ export async function setupPlugin(pluginName, services) {
|
|
|
634
698
|
}
|
|
635
699
|
}
|
|
636
700
|
|
|
637
|
-
// Roles
|
|
701
|
+
// Roles and permissions - see registerPluginAccess()
|
|
638
702
|
if (services.roles) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
703
|
+
result.roles.push(...(await registerPluginAccess(pluginName, services.roles)).roles);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Register what a plugin declares about access: the permission resources it
|
|
711
|
+
* guards its own routes with, the roles it brings, and which of the site's
|
|
712
|
+
* existing roles should start out holding its permissions.
|
|
713
|
+
*
|
|
714
|
+
* Two places declare these, and both are read:
|
|
715
|
+
*
|
|
716
|
+
* plugin.json "permissions": [{key, label?, actions?, group?, grant?}]
|
|
717
|
+
* roles/*.json {name, label, level, permissions, badgeClass?, resources?: [...]}
|
|
718
|
+
*
|
|
719
|
+
* A resource's `grant: ["admin"]` gives those roles the permission once; a
|
|
720
|
+
* role file creates its role once. After that both belong to the admin: an
|
|
721
|
+
* edited role is never overwritten and a revoked grant is never re-added.
|
|
722
|
+
*
|
|
723
|
+
* Resources live in memory, so this runs on every boot for each enabled
|
|
724
|
+
* plugin as well as on enable - otherwise a plugin's permissions vanish from
|
|
725
|
+
* the role editor after the first restart.
|
|
726
|
+
*
|
|
727
|
+
* @param {string} pluginName
|
|
728
|
+
* @param {object} rolesService - server/services/roles.js (injectable for tests)
|
|
729
|
+
* @returns {Promise<{roles: string[], resources: string[]}>}
|
|
730
|
+
*/
|
|
731
|
+
export async function registerPluginAccess(pluginName, rolesService) {
|
|
732
|
+
const out = {roles: [], resources: []};
|
|
733
|
+
const pluginDir = path.join(PLUGINS_DIR, pluginName);
|
|
734
|
+
const grants = [];
|
|
735
|
+
|
|
736
|
+
const addResource = (resource) => {
|
|
737
|
+
if (!resource || typeof resource.key !== 'string' || !resource.key) return;
|
|
738
|
+
const {grant, ...definition} = resource;
|
|
739
|
+
registerPluginResource(definition, pluginName);
|
|
740
|
+
out.resources.push(resource.key);
|
|
741
|
+
for (const role of Array.isArray(grant) ? grant : []) {
|
|
742
|
+
if (typeof role === 'string' && role) grants.push([role, resource.key]);
|
|
645
743
|
}
|
|
646
|
-
|
|
647
|
-
try {
|
|
648
|
-
const raw = await fs.readFile(path.join(rolesDir, file), 'utf8');
|
|
649
|
-
const def = JSON.parse(raw);
|
|
650
|
-
|
|
651
|
-
// Register any declared resources into the permission overlay
|
|
652
|
-
if (Array.isArray(def.resources)) {
|
|
653
|
-
for (const resource of def.resources) {
|
|
654
|
-
registerPluginResource(resource, pluginName);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
744
|
+
};
|
|
657
745
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
746
|
+
try {
|
|
747
|
+
const manifest = JSON.parse(await fs.readFile(path.join(pluginDir, 'plugin.json'), 'utf8'));
|
|
748
|
+
if (Array.isArray(manifest.permissions)) manifest.permissions.forEach(addResource);
|
|
749
|
+
} catch { /* no manifest permissions */ }
|
|
750
|
+
|
|
751
|
+
let roleFiles = [];
|
|
752
|
+
try {
|
|
753
|
+
roleFiles = (await fs.readdir(path.join(pluginDir, 'roles'))).filter(f => f.endsWith('.json'));
|
|
754
|
+
} catch { /* no roles dir */ }
|
|
755
|
+
|
|
756
|
+
const roleDefs = [];
|
|
757
|
+
for (const file of roleFiles) {
|
|
758
|
+
try {
|
|
759
|
+
const def = JSON.parse(await fs.readFile(path.join(pluginDir, 'roles', file), 'utf8'));
|
|
760
|
+
if (Array.isArray(def.resources)) def.resources.forEach(addResource);
|
|
761
|
+
if (typeof def.name === 'string' && def.name) roleDefs.push(def);
|
|
762
|
+
} catch { /* skip an unreadable role file */ }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
for (const def of roleDefs) {
|
|
766
|
+
const {resources: _resources, ...roleData} = def;
|
|
767
|
+
if (validateRoleDef(roleData)) continue;
|
|
768
|
+
try {
|
|
769
|
+
const ensure = rolesService.ensureRole || rolesService.createRole;
|
|
770
|
+
await ensure({...roleData, plugin: pluginName});
|
|
771
|
+
out.roles.push(def.name);
|
|
772
|
+
} catch { /* skip */ }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
for (const [role, permission] of grants) {
|
|
776
|
+
try {
|
|
777
|
+
await rolesService.grantPermissionOnce?.(role, permission, pluginName);
|
|
778
|
+
} catch { /* a role that does not exist is simply not granted */ }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Bare permissions ('blog-posts') expand to the resource's actions when
|
|
782
|
+
// the cache is built, which happened before these resources existed.
|
|
783
|
+
if (out.resources.length) await rolesService.invalidate?.();
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Role names: what a role slug has always looked like in the roles collection. */
|
|
788
|
+
const ROLE_NAME_RE = /^[a-z0-9][a-z0-9-_]{0,63}$/;
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Why a role definition is unusable, or null if it is fine. Level 0 is the
|
|
792
|
+
* root role; a plugin can never mint another one.
|
|
793
|
+
*
|
|
794
|
+
* @param {object} def
|
|
795
|
+
* @returns {string|null}
|
|
796
|
+
*/
|
|
797
|
+
function validateRoleDef(def) {
|
|
798
|
+
if (!def || typeof def !== 'object') return 'a role definition object is required';
|
|
799
|
+
if (typeof def.name !== 'string' || !ROLE_NAME_RE.test(def.name)) {
|
|
800
|
+
return 'name must be lowercase letters, digits, "-" or "_" (max 64)';
|
|
801
|
+
}
|
|
802
|
+
if (!Number.isInteger(def.level) || def.level < 1) return 'level must be a whole number of 1 or more';
|
|
803
|
+
if (def.label != null && typeof def.label !== 'string') return 'label must be a string';
|
|
804
|
+
if (def.permissions != null && !(Array.isArray(def.permissions) && def.permissions.every(p => typeof p === 'string'))) {
|
|
805
|
+
return 'permissions must be an array of strings';
|
|
806
|
+
}
|
|
807
|
+
if (def.admin != null && !normaliseAdminScope(def.admin)) {
|
|
808
|
+
return 'admin must be {home?, allow: ["/route", ...]} with at least one /-rooted route';
|
|
809
|
+
}
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Register a role on behalf of a plugin - the code behind `hooks.registerRole()`.
|
|
815
|
+
*
|
|
816
|
+
* Same rules as a `roles/*.json` file: created once, then the admin's. Calling
|
|
817
|
+
* it again (every boot, say) is a no-op for a role this plugin already owns,
|
|
818
|
+
* so an admin's edits survive. A name held by the site or by another plugin is
|
|
819
|
+
* refused with an error rather than skipped - a plugin asking for `admin`
|
|
820
|
+
* should find out, not silently share the site's role.
|
|
821
|
+
*
|
|
822
|
+
* `resources` (permission definitions, as in plugin.json `permissions`) are
|
|
823
|
+
* registered too, `grant` included.
|
|
824
|
+
*
|
|
825
|
+
* @param {string} pluginName
|
|
826
|
+
* @param {{name:string,label?:string,level:number,permissions?:string[],badgeClass?:string,resources?:object[]}} def
|
|
827
|
+
* @param {object} [rolesService]
|
|
828
|
+
* @returns {Promise<{name: string, created: boolean}>}
|
|
829
|
+
*/
|
|
830
|
+
export async function registerRoleForPlugin(pluginName, def, rolesService = defaultRolesService) {
|
|
831
|
+
if (!def || typeof def !== 'object') throw new Error('registerRole: a role definition object is required');
|
|
832
|
+
const {resources, ...roleData} = def;
|
|
833
|
+
const problem = validateRoleDef(roleData);
|
|
834
|
+
if (problem) throw new Error(`registerRole: ${problem}`);
|
|
835
|
+
|
|
836
|
+
const existing = await rolesService.getRoleData(roleData.name);
|
|
837
|
+
if (existing && existing.plugin !== pluginName) {
|
|
838
|
+
throw new Error(`registerRole: role "${roleData.name}" already exists and does not belong to plugin "${pluginName}"`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
let registered = false;
|
|
842
|
+
for (const resource of Array.isArray(resources) ? resources : []) {
|
|
843
|
+
if (!resource || typeof resource.key !== 'string' || !resource.key) continue;
|
|
844
|
+
const {grant, ...definition} = resource;
|
|
845
|
+
registerPluginResource(definition, pluginName);
|
|
846
|
+
registered = true;
|
|
847
|
+
for (const role of Array.isArray(grant) ? grant : []) {
|
|
848
|
+
if (typeof role === 'string' && role) {
|
|
849
|
+
await rolesService.grantPermissionOnce(role, resource.key, pluginName).catch(() => {});
|
|
664
850
|
}
|
|
665
851
|
}
|
|
666
852
|
}
|
|
667
853
|
|
|
668
|
-
|
|
854
|
+
const created = existing ? false : await rolesService.ensureRole({
|
|
855
|
+
label: roleData.name, permissions: [], ...roleData, plugin: pluginName
|
|
856
|
+
});
|
|
857
|
+
if (registered) await rolesService.invalidate();
|
|
858
|
+
return {name: roleData.name, created: !!created};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Remove a role a plugin registered - the code behind `hooks.unregisterRole()`.
|
|
863
|
+
*
|
|
864
|
+
* Only the owning plugin can remove its role; the site's roles and other
|
|
865
|
+
* plugins' roles are refused. Users are moved off the role in the same step:
|
|
866
|
+
* one holding it as their primary role becomes `user` (the base role that
|
|
867
|
+
* always exists), and it is dropped from everyone's additional roles. That is
|
|
868
|
+
* what the next boot would do anyway (seed() demotes unknown roles), done now
|
|
869
|
+
* so nobody is left holding a role that grants nothing.
|
|
870
|
+
*
|
|
871
|
+
* @param {string} pluginName
|
|
872
|
+
* @param {string} name
|
|
873
|
+
* @param {{rolesService?: object, usersService?: object}} [services]
|
|
874
|
+
* @returns {Promise<{name: string, removed: boolean, usersReassigned: number}>}
|
|
875
|
+
*/
|
|
876
|
+
export async function unregisterRoleForPlugin(pluginName, name, {
|
|
877
|
+
rolesService = defaultRolesService,
|
|
878
|
+
usersService = defaultUsersService
|
|
879
|
+
} = {}) {
|
|
880
|
+
const existing = await rolesService.getRoleData(name);
|
|
881
|
+
if (!existing) return {name, removed: false, usersReassigned: 0};
|
|
882
|
+
if (existing.plugin !== pluginName) {
|
|
883
|
+
throw new Error(`unregisterRole: role "${name}" does not belong to plugin "${pluginName}"`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
await rolesService.removeRole(name);
|
|
887
|
+
|
|
888
|
+
let usersReassigned = 0;
|
|
889
|
+
for (const user of await usersService.listUsers()) {
|
|
890
|
+
const additional = Array.isArray(user.additionalRoles) ? user.additionalRoles : [];
|
|
891
|
+
const holdsPrimary = user.role === name;
|
|
892
|
+
if (!holdsPrimary && !additional.includes(name)) continue;
|
|
893
|
+
await usersService.updateUser(user.id, {
|
|
894
|
+
...(holdsPrimary && {role: 'user'}),
|
|
895
|
+
additionalRoles: additional.filter(r => r !== name)
|
|
896
|
+
});
|
|
897
|
+
usersReassigned++;
|
|
898
|
+
}
|
|
899
|
+
return {name, removed: true, usersReassigned};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Remove every role a plugin owns - used when the plugin is uninstalled.
|
|
904
|
+
* (Disabling keeps them; see teardownPlugin.)
|
|
905
|
+
*
|
|
906
|
+
* @param {string} pluginName
|
|
907
|
+
* @param {{rolesService?: object, usersService?: object}} [services]
|
|
908
|
+
* @returns {Promise<string[]>} the removed role names
|
|
909
|
+
*/
|
|
910
|
+
export async function unregisterAllRolesForPlugin(pluginName, services = {}) {
|
|
911
|
+
const rolesService = services.rolesService || defaultRolesService;
|
|
912
|
+
const owned = [...rolesService.getRoleMap().keys()];
|
|
913
|
+
const removed = [];
|
|
914
|
+
for (const name of owned) {
|
|
915
|
+
const data = await rolesService.getRoleData(name);
|
|
916
|
+
if (data?.plugin !== pluginName) continue;
|
|
917
|
+
const res = await unregisterRoleForPlugin(pluginName, name, services);
|
|
918
|
+
if (res.removed) removed.push(name);
|
|
919
|
+
}
|
|
920
|
+
return removed;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The role hooks handed to one plugin, bound to its name so it can only ever
|
|
925
|
+
* register and remove roles of its own.
|
|
926
|
+
*
|
|
927
|
+
* @param {string} pluginName
|
|
928
|
+
* @returns {{registerRole: Function, unregisterRole: Function}}
|
|
929
|
+
*/
|
|
930
|
+
export function pluginRoleHooks(pluginName) {
|
|
931
|
+
return {
|
|
932
|
+
registerRole: (def) => registerRoleForPlugin(pluginName, def),
|
|
933
|
+
unregisterRole: (name) => unregisterRoleForPlugin(pluginName, name)
|
|
934
|
+
};
|
|
669
935
|
}
|
|
670
936
|
|
|
671
937
|
/**
|
|
@@ -714,16 +980,11 @@ export async function teardownPlugin(pluginName, services, fastify) {
|
|
|
714
980
|
}
|
|
715
981
|
}
|
|
716
982
|
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
result.roles.push(pluginName);
|
|
723
|
-
} catch (err) {
|
|
724
|
-
fastify.log.warn(`[plugins] Could not remove roles for "${pluginName}": ${err.message}`);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
983
|
+
// Permissions - unregister the plugin's resources. Its roles are KEPT: a
|
|
984
|
+
// role removed here would leave every user holding it with a role the
|
|
985
|
+
// next boot's migrateUserRoles() rewrites to 'user', so a disable/enable
|
|
986
|
+
// cycle would silently demote them. Like collections, they stay put.
|
|
987
|
+
unregisterPluginResourcesByPlugin(pluginName);
|
|
727
988
|
|
|
728
989
|
if (_loadedPlugins[pluginName]) {
|
|
729
990
|
_loadedPlugins[pluginName].enabled = false;
|
|
@@ -797,11 +1058,10 @@ export async function runLifecycleHook(name, hook, fastify) {
|
|
|
797
1058
|
const torn = await teardownPlugin(name, services, fastify);
|
|
798
1059
|
if (torn.pages.length) fastify.log.info(`[plugins] Removed pages for "${name}": ${torn.pages.join(', ')}`);
|
|
799
1060
|
if (torn.forms.length) fastify.log.info(`[plugins] Removed forms for "${name}": ${torn.forms.join(', ')}`);
|
|
800
|
-
if (torn.roles.length) fastify.log.info(`[plugins] Removed roles for "${name}"`);
|
|
801
1061
|
}
|
|
802
1062
|
|
|
803
1063
|
if (typeof mod[hook] === 'function') {
|
|
804
|
-
await mod[hook]({ fastify, services });
|
|
1064
|
+
await mod[hook]({ fastify, services, hooks: pluginRoleHooks(name) });
|
|
805
1065
|
}
|
|
806
1066
|
} catch (err) {
|
|
807
1067
|
fastify.log.error(`Plugin "${name}" lifecycle hook "${hook}" failed: ${err.message}`);
|
|
@@ -908,6 +1168,33 @@ export function pluginExpiry(manifest, given) {
|
|
|
908
1168
|
: null;
|
|
909
1169
|
}
|
|
910
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
|
+
|
|
911
1198
|
export async function getAdminPluginConfig() {
|
|
912
1199
|
const manifests = await discoverPlugins();
|
|
913
1200
|
const states = getPluginStates();
|
|
@@ -955,6 +1242,7 @@ export async function getAdminPluginConfig() {
|
|
|
955
1242
|
meta[viewName] = {
|
|
956
1243
|
plugin: manifest.name,
|
|
957
1244
|
displayName: manifest.displayName || manifest.name,
|
|
1245
|
+
section: viewSection(manifest, viewName),
|
|
958
1246
|
version: manifest.version || '1.0.0',
|
|
959
1247
|
date: manifest.date || '',
|
|
960
1248
|
author: manifest.author || '',
|
|
@@ -984,5 +1272,5 @@ export async function getAdminPluginConfig() {
|
|
|
984
1272
|
}
|
|
985
1273
|
}
|
|
986
1274
|
|
|
987
|
-
return { sidebar, routes, views, css, meta };
|
|
1275
|
+
return { sidebar, routes, views, css, meta, takeovers: getToolTakeovers() };
|
|
988
1276
|
}
|