domma-cms 0.74.0 → 0.76.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 +3 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/actions-arrange.js +1 -0
- package/admin/js/lib/api-endpoints-arrange.js +1 -0
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/blocks-arrange.js +1 -0
- package/admin/js/lib/collections-arrange.js +1 -0
- package/admin/js/lib/components-arrange.js +1 -0
- package/admin/js/lib/context-menus-arrange.js +1 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/forms-arrange.js +1 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/media-arrange.js +1 -0
- package/admin/js/lib/menus-arrange.js +1 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/pages-arrange.js +1 -0
- package/admin/js/lib/plugins-arrange.js +2 -0
- package/admin/js/lib/roles-arrange.js +1 -0
- package/admin/js/lib/search-settings-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/theme-settings-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/lib/views-arrange.js +1 -0
- package/admin/js/templates/actions-list.html +73 -14
- package/admin/js/templates/api-endpoints.html +63 -10
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/blocks.html +76 -15
- package/admin/js/templates/collections.html +108 -52
- package/admin/js/templates/components.html +82 -15
- package/admin/js/templates/context-menus.html +55 -13
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/forms.html +67 -14
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/media.html +99 -13
- package/admin/js/templates/menus.html +57 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/pages.html +78 -23
- package/admin/js/templates/plugins.html +100 -28
- package/admin/js/templates/roles.html +78 -7
- package/admin/js/templates/search.html +193 -216
- package/admin/js/templates/settings.html +404 -472
- package/admin/js/templates/theme.html +164 -132
- package/admin/js/templates/users.html +69 -9
- package/admin/js/templates/views-list.html +63 -9
- package/admin/js/views/actions-list.js +37 -1
- package/admin/js/views/api-endpoints.js +11 -7
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/blocks.js +34 -5
- package/admin/js/views/collections.js +36 -1
- package/admin/js/views/components.js +27 -5
- package/admin/js/views/context-menus.js +10 -5
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/forms.js +57 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/media.js +40 -1
- package/admin/js/views/menus.js +11 -5
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/pages.js +28 -8
- package/admin/js/views/plugins.js +97 -39
- package/admin/js/views/roles.js +49 -4
- package/admin/js/views/search.js +7 -1
- package/admin/js/views/settings.js +13 -3
- package/admin/js/views/theme.js +55 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/views-list.js +11 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +97 -40
- package/plugins/_lib/admin/mail/contacts.js +4 -1
- package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
- package/plugins/_lib/admin/mail/folder-tree.js +1 -1
- package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/mail.html +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +303 -44
- package/plugins/_lib/admin/mail/when-closed.js +26 -0
- package/plugins/_lib/mail/readRoutes.js +43 -1
- package/plugins/_lib/mail/unreadBadge.js +42 -0
- package/plugins/free-tier.lock.json +5 -5
- package/plugins/mail-reader/CLAUDE.md +1 -0
- package/plugins/mail-reader/plugin.js +3 -1
- package/plugins/mail-reader/plugin.json +5 -4
- package/server/routes/api/actions.js +64 -2
- package/server/routes/api/api-endpoints.js +41 -0
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/blocks.js +170 -8
- package/server/routes/api/collections.js +66 -2
- package/server/routes/api/components.js +46 -0
- package/server/routes/api/context-menus.js +8 -0
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +123 -10
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/media.js +56 -1
- package/server/routes/api/menus.js +53 -6
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/pages.js +46 -0
- package/server/routes/api/plugins.js +135 -4
- package/server/routes/api/roles.js +111 -0
- package/server/routes/api/search.js +38 -4
- package/server/routes/api/settings.js +52 -0
- package/server/routes/api/theme.js +55 -0
- package/server/routes/api/users.js +53 -1
- package/server/routes/api/views.js +27 -3
- package/server/server.js +28 -1
- package/server/services/contextMenus.js +41 -0
- package/server/services/plugins.js +7 -0
- package/server/services/roles.js +3 -0
- package/server/services/search.js +58 -0
- package/server/services/users.js +14 -0
- package/server/services/views.js +38 -0
- package/admin/js/templates/plugin-marketplace.html +0 -29
- package/admin/js/views/plugin-marketplace.js +0 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roles - the screen's own routes. Role records themselves are the preset
|
|
3
|
+
* `roles` collection and are read and written through the collections API
|
|
4
|
+
* (`/api/collections/roles/entries`, with keepRoleSystemFields); these add
|
|
5
|
+
* what that API cannot say.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/roles/_holders - who holds each role: {counts: {name: n}, orphans: [...]}
|
|
8
|
+
* GET /api/roles/_count - the sidebar badge on Roles
|
|
9
|
+
*
|
|
10
|
+
* The underscore keeps both clear of any role name (role names are slugs).
|
|
11
|
+
* Guarded like the list they sit beside: `collections` read.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
authenticate as defaultAuthenticate,
|
|
15
|
+
requirePermission as defaultRequirePermission
|
|
16
|
+
} from '../../middleware/auth.js';
|
|
17
|
+
import {listEntries} from '../../services/collections.js';
|
|
18
|
+
import {listUsers} from '../../services/users.js';
|
|
19
|
+
|
|
20
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How many users hold each role - primary or additional - and which users
|
|
24
|
+
* hold a role that no longer exists (they fall back to the least-privileged
|
|
25
|
+
* role until an admin fixes them).
|
|
26
|
+
*
|
|
27
|
+
* @param {object[]} users
|
|
28
|
+
* @param {string[]} roleNames - the roles that exist
|
|
29
|
+
* @returns {{counts: Record<string, number>, orphans: {role: string}[]}} - one orphan per user-role pair
|
|
30
|
+
*/
|
|
31
|
+
export function roleHolders(users = [], roleNames = []) {
|
|
32
|
+
const known = new Set(roleNames);
|
|
33
|
+
const counts = Object.fromEntries(roleNames.map(n => [n, 0]));
|
|
34
|
+
const orphans = [];
|
|
35
|
+
for (const u of users) {
|
|
36
|
+
const held = [...new Set([u.role, ...(Array.isArray(u.additionalRoles) ? u.additionalRoles : [])].filter(Boolean))];
|
|
37
|
+
for (const r of held) {
|
|
38
|
+
if (known.has(r)) counts[r]++;
|
|
39
|
+
// The role only - who they are is Users' business (users.read), not
|
|
40
|
+
// something to hand to anyone who can read the roles list.
|
|
41
|
+
else orphans.push({role: r});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {counts, orphans};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The badge: how many roles (an inventory - the number never alarms), red
|
|
49
|
+
* while any user holds a role that no longer exists. Plain text throughout;
|
|
50
|
+
* the sidebar popover sets it with textContent.
|
|
51
|
+
*
|
|
52
|
+
* @param {{name: string, label?: string, level: number, plugin?: string}[]} roles
|
|
53
|
+
* @param {{counts: Record<string, number>, orphans: {user: string, role: string}[]}} holders
|
|
54
|
+
*/
|
|
55
|
+
export function rolesBadge(roles = [], {counts = {}, orphans = []} = {}) {
|
|
56
|
+
const n = roles.length;
|
|
57
|
+
const fromPlugins = roles.filter(r => r.plugin).length;
|
|
58
|
+
const unused = roles.filter(r => r.level !== 0 && !counts[r.name]).length;
|
|
59
|
+
const sorted = [...roles].sort((a, b) => (a.level ?? 99) - (b.level ?? 99) || String(a.name).localeCompare(String(b.name)));
|
|
60
|
+
return {
|
|
61
|
+
count: n,
|
|
62
|
+
label: orphans.length
|
|
63
|
+
? `${plural(orphans.length, 'user')} ${orphans.length === 1 ? 'holds' : 'hold'} a role that no longer exists`
|
|
64
|
+
: `${plural(n, 'role')}${fromPlugins ? `, ${fromPlugins} from plugins` : ''}`,
|
|
65
|
+
tone: orphans.length ? 'danger' : 'info',
|
|
66
|
+
details: {
|
|
67
|
+
title: 'Roles',
|
|
68
|
+
rows: [
|
|
69
|
+
{label: 'Roles', value: String(n)},
|
|
70
|
+
{label: 'From plugins', value: String(fromPlugins)},
|
|
71
|
+
{label: 'Held by nobody', value: String(unused)},
|
|
72
|
+
...(orphans.length ? [{label: 'Users on a missing role', value: String(orphans.length), tone: 'danger'}] : [])
|
|
73
|
+
],
|
|
74
|
+
items: orphans.length
|
|
75
|
+
? Object.entries(orphans.reduce((m, o) => ({...m, [o.role]: (m[o.role] || 0) + 1}), {})).slice(0, 8)
|
|
76
|
+
.map(([role, k]) => ({text: `"${role}"`, meta: `no longer exists - ${plural(k, 'user')} still ${k === 1 ? 'holds' : 'hold'} it`, tone: 'danger'}))
|
|
77
|
+
: sorted.slice(0, 8).map(r => ({
|
|
78
|
+
text: String(r.label || r.name),
|
|
79
|
+
meta: `level ${r.level} · ${plural(counts[r.name] || 0, 'user')}${r.plugin ? ` · ${r.plugin}` : ''}`
|
|
80
|
+
})),
|
|
81
|
+
empty: 'No roles.'
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
88
|
+
* @param {{authenticate?: Function, requirePermission?: Function}} [opts]
|
|
89
|
+
*/
|
|
90
|
+
export async function rolesRoutes(fastify, opts = {}) {
|
|
91
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
92
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
93
|
+
// `plugins`: the permission the Roles sidebar entry (and menu item) asks for, so
|
|
94
|
+
// whoever sees the entry can read its badge - with `collections` it 403'd every minute.
|
|
95
|
+
const canRead = {preHandler: [authenticate, requirePermission('plugins', 'read')]};
|
|
96
|
+
|
|
97
|
+
const current = async () => {
|
|
98
|
+
// The collection's entries, not the role cache: the cache drops `plugin`.
|
|
99
|
+
const {entries} = await listEntries('roles', {limit: 0});
|
|
100
|
+
const roles = entries.map(e => e.data || {}).filter(r => r.name);
|
|
101
|
+
const holders = roleHolders(await listUsers().catch(() => []), roles.map(r => r.name));
|
|
102
|
+
return {roles, holders};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
fastify.get('/roles/_holders', canRead, async () => (await current()).holders);
|
|
106
|
+
|
|
107
|
+
fastify.get('/roles/_count', canRead, async () => {
|
|
108
|
+
const {roles, holders} = await current();
|
|
109
|
+
return rolesBadge(roles, holders);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -4,17 +4,35 @@
|
|
|
4
4
|
* GET /api/search?q=… - public: full-text search across published pages
|
|
5
5
|
* GET /api/search/settings - public: the search UI's own configuration
|
|
6
6
|
* PUT /api/search/settings - admin-only: save it
|
|
7
|
+
* GET /api/search/preview?q=…&max=… - admin-only: the Search screen's "Try it" -
|
|
8
|
+
* the same search, run with the max-results being edited (not yet saved)
|
|
9
|
+
* and without the minimum length, so a setting can be tried before Save
|
|
10
|
+
* GET /api/search/_count - admin-only: the sidebar badge (searchBadge)
|
|
7
11
|
*
|
|
8
12
|
* The public settings endpoint is kept for anything already calling it (and
|
|
9
13
|
* for a page served from a cache older than the feature), but it is no longer
|
|
10
14
|
* on the critical path: the renderer embeds the same object in the page as
|
|
11
15
|
* `window.__CMS_SEARCH__`, so the overlay can draw itself without a round trip.
|
|
12
16
|
*/
|
|
13
|
-
import {authenticate, requireAdmin
|
|
17
|
+
import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin,
|
|
18
|
+
requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
|
|
14
19
|
import {listPages} from '../../services/content.js';
|
|
15
|
-
import {getSearchSettings, saveSearchSettings, searchPages} from '../../services/search.js';
|
|
20
|
+
import {getSearchSettings, saveSearchSettings, searchBadge, searchCoverage, searchPages} from '../../services/search.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
24
|
+
* @param {{authenticate?: Function, requireAdmin?: Function}} [opts] - DI for tests
|
|
25
|
+
*/
|
|
26
|
+
export async function searchRoutes(fastify, opts = {}) {
|
|
27
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
28
|
+
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
29
|
+
const adminOnly = {preHandler: [authenticate, requireAdmin]};
|
|
30
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
31
|
+
// The badge follows the Search sidebar entry's own permission (settings), so whoever
|
|
32
|
+
// sees the entry can read it - admin-only, it 403'd every minute for the rest. It
|
|
33
|
+
// tells nothing a visitor's search does not: public pages only.
|
|
34
|
+
const canReadBadge = {preHandler: [authenticate, requirePermission('settings', 'read')]};
|
|
16
35
|
|
|
17
|
-
export async function searchRoutes(fastify) {
|
|
18
36
|
fastify.get('/search', async (request, reply) => {
|
|
19
37
|
const settings = getSearchSettings();
|
|
20
38
|
const q = (request.query.q || '').trim();
|
|
@@ -37,7 +55,23 @@ export async function searchRoutes(fastify) {
|
|
|
37
55
|
|
|
38
56
|
fastify.get('/search/settings', async () => getSearchSettings());
|
|
39
57
|
|
|
40
|
-
fastify.put('/search/settings',
|
|
58
|
+
fastify.put('/search/settings', adminOnly, async (request) => {
|
|
41
59
|
return {ok: true, settings: saveSearchSettings(request.body || {})};
|
|
42
60
|
});
|
|
61
|
+
|
|
62
|
+
fastify.get('/search/preview', adminOnly, async (request, reply) => {
|
|
63
|
+
const q = String(request.query.q || '').trim().slice(0, 200);
|
|
64
|
+
if (!q) return reply.status(400).send({error: 'Type something to search for'});
|
|
65
|
+
const settings = getSearchSettings();
|
|
66
|
+
const asked = parseInt(request.query.max, 10);
|
|
67
|
+
const maxResults = Number.isFinite(asked) ? Math.min(50, Math.max(1, asked)) : settings.maxResults;
|
|
68
|
+
const pages = await listPages();
|
|
69
|
+
const results = searchPages(pages, q, {
|
|
70
|
+
maxResults,
|
|
71
|
+
onError: (err, page) => request.log.warn({err, page: page.urlPath}, '[search] skipped an unreadable page')
|
|
72
|
+
});
|
|
73
|
+
return {q, maxResults, results, coverage: searchCoverage(pages)};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
fastify.get('/search/_count', canReadBadge, async () => searchBadge(getSearchSettings(), await listPages()));
|
|
43
77
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* GET /api/settings - get site settings
|
|
4
4
|
* PUT /api/settings - save site settings
|
|
5
5
|
* POST /api/settings/test-email - send a test email using stored SMTP config
|
|
6
|
+
* GET /api/settings/_count - the sidebar badge: what is wrong with the settings
|
|
6
7
|
*/
|
|
7
8
|
import {getConfig, saveConfig} from '../../config.js';
|
|
8
9
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
@@ -18,6 +19,50 @@ const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
|
|
|
18
19
|
const CONNECTIONS_FILE = path.resolve(__dirname, '../../../config/connections.json');
|
|
19
20
|
const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The sidebar badge on Site Settings (its countUrl): the settings that are
|
|
24
|
+
* quietly wrong, worst first - chiefly no SMTP host, where the CMS sends every
|
|
25
|
+
* mail to a throwaway test inbox that accepts it and reports success, so
|
|
26
|
+
* nothing else in the admin looks wrong. Nothing wrong = no badge. The same
|
|
27
|
+
* rules as settingsProblems() in admin/js/lib/site-settings-arrange.js.
|
|
28
|
+
* Plain text throughout - the popover sets it with textContent.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} site - config/site.json
|
|
31
|
+
* @param {{enabled?: boolean, driver?: string}} [cacheStats]
|
|
32
|
+
*/
|
|
33
|
+
export function settingsBadge(site = {}, cacheStats = {}) {
|
|
34
|
+
const smtp = site.smtp || {};
|
|
35
|
+
const cc = site.cookieConsent || {};
|
|
36
|
+
const problems = [];
|
|
37
|
+
if (!String(smtp.host || '').trim()) {
|
|
38
|
+
problems.push({tone: 'danger', text: 'No SMTP host', meta: 'mail goes to a test inbox and reaches no one'});
|
|
39
|
+
} else if (!String(smtp.fromAddress || '').trim()) {
|
|
40
|
+
problems.push({tone: 'warn', text: 'No From address', meta: 'mail has no sender; many servers refuse it'});
|
|
41
|
+
}
|
|
42
|
+
if (cc.enabled !== false && !String(cc.privacyPolicyUrl || '').trim()) {
|
|
43
|
+
problems.push({tone: 'warn', text: 'Cookie banner without a privacy policy', meta: 'it links to nothing'});
|
|
44
|
+
}
|
|
45
|
+
const danger = problems.some(p => p.tone === 'danger');
|
|
46
|
+
const n = problems.length;
|
|
47
|
+
return {
|
|
48
|
+
count: n,
|
|
49
|
+
label: n ? `${n} setting${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention` : 'Settings look fine',
|
|
50
|
+
tone: danger ? 'danger' : 'attention',
|
|
51
|
+
details: {
|
|
52
|
+
title: 'Site Settings',
|
|
53
|
+
rows: [
|
|
54
|
+
{label: 'Site title', value: String(site.title || '-')},
|
|
55
|
+
{label: 'Admin home', value: String(site.adminHome || '/')},
|
|
56
|
+
{label: 'Email', value: smtp.host ? String(smtp.host) : 'not configured', ...(!smtp.host && {tone: 'danger'})},
|
|
57
|
+
{label: 'Cookie banner', value: cc.enabled !== false ? 'on' : 'off'},
|
|
58
|
+
{label: 'Cache', value: cacheStats.enabled ? `on (${cacheStats.driver || 'memory'})` : 'off'}
|
|
59
|
+
],
|
|
60
|
+
items: problems.map(p => ({text: p.text, meta: p.meta, ...(p.tone === 'danger' && {tone: 'danger'})})),
|
|
61
|
+
empty: 'Nothing needs attention.'
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
21
66
|
export async function settingsRoutes(fastify) {
|
|
22
67
|
const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
|
|
23
68
|
const canUpdate = {preHandler: [authenticate, requirePermission('settings', 'update')]};
|
|
@@ -112,6 +157,13 @@ export async function settingsRoutes(fastify) {
|
|
|
112
157
|
}
|
|
113
158
|
});
|
|
114
159
|
|
|
160
|
+
// The sidebar badge. `_count` sits beside fixed paths only - nothing can shadow it.
|
|
161
|
+
fastify.get('/settings/_count', canRead, async () => {
|
|
162
|
+
let stats = {};
|
|
163
|
+
try { stats = cache.getStats(); } catch { /* no cache - reported off */ }
|
|
164
|
+
return settingsBadge(getConfig('site') || {}, stats);
|
|
165
|
+
});
|
|
166
|
+
|
|
115
167
|
// GET /api/settings/db-status - returns whether MongoDB connections are configured
|
|
116
168
|
fastify.get('/settings/db-status', canRead, async () => {
|
|
117
169
|
try {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* user - everyone's admin needs one)
|
|
11
11
|
* POST /api/theme/compile - compile a candidate config to CSS without saving,
|
|
12
12
|
* so the admin previews exactly what the page gets
|
|
13
|
+
* GET /api/theme/count - the sidebar badge on Theme (themeBadge below)
|
|
13
14
|
*
|
|
14
15
|
* Auth middlewares are injectable so tests can pass no-ops, the same DI pattern
|
|
15
16
|
* as menus.js and context-menus.js.
|
|
@@ -32,6 +33,57 @@ import {
|
|
|
32
33
|
validateThemeConfig
|
|
33
34
|
} from '../../services/themeSettings.js';
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The sidebar badge on Content › Theme. It REPLACES the core count the sidebar
|
|
38
|
+
* shows there (how many element overrides - a registered countUrl wins), so it
|
|
39
|
+
* keeps that number, and adds two things: a popover saying what is live where,
|
|
40
|
+
* and a red count when a theme in use no longer exists (a custom theme whose
|
|
41
|
+
* plugin went away - the page keeps its base, but the look it was given is
|
|
42
|
+
* gone). Plain text throughout.
|
|
43
|
+
*
|
|
44
|
+
* Mirrors themeProblems() in admin/js/lib/theme-settings-arrange.js.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} cfg
|
|
47
|
+
* @param {string[]} builtins - listThemeIds()
|
|
48
|
+
* @param {string[]} custom - ids of the custom themes plugins offer now
|
|
49
|
+
*/
|
|
50
|
+
/** Themes Domma knows without the stylesheet declaring them (resolveThemeClasses passes them through). */
|
|
51
|
+
export const DOMMA_OWN_THEMES = ['core-light', 'core-dark'];
|
|
52
|
+
|
|
53
|
+
export function themeBadge(cfg = {}, builtins = [], custom = []) {
|
|
54
|
+
// No catalogue (the stylesheet did not load) means nothing can be judged -
|
|
55
|
+
// the same rule as themeProblems() in admin/js/lib/theme-settings-arrange.js.
|
|
56
|
+
const known = builtins.length ? new Set([...builtins, ...custom, ...DOMMA_OWN_THEMES]) : null;
|
|
57
|
+
const auto = cfg.autoTheme?.enabled ? cfg.autoTheme : null;
|
|
58
|
+
const problems = [];
|
|
59
|
+
const check = (where, id) => { if (known && id && !known.has(id)) problems.push({where, id}); };
|
|
60
|
+
if (auto) { check('Day theme', auto.dayTheme); check('Night theme', auto.nightTheme); }
|
|
61
|
+
else check('Site theme', cfg.theme);
|
|
62
|
+
check('Admin theme', cfg.adminTheme);
|
|
63
|
+
const overrides = Array.isArray(cfg.overrides) ? cfg.overrides : [];
|
|
64
|
+
overrides.forEach((o, i) => check(`Override "${o.label || o.scope || i + 1}"`, o.theme));
|
|
65
|
+
const on = overrides.filter(o => o.enabled !== false).length;
|
|
66
|
+
const n = problems.length;
|
|
67
|
+
const plural = (k, one) => `${k} ${one}${k === 1 ? '' : 's'}`;
|
|
68
|
+
return {
|
|
69
|
+
count: n || overrides.length,
|
|
70
|
+
label: n ? `${plural(n, 'theme')} in use no longer available` : plural(overrides.length, 'element override'),
|
|
71
|
+
tone: n ? 'danger' : 'info',
|
|
72
|
+
details: {
|
|
73
|
+
title: 'Theme',
|
|
74
|
+
rows: [
|
|
75
|
+
auto
|
|
76
|
+
? {label: 'Site (day / night)', value: `${auto.dayTheme} / ${auto.nightTheme}`}
|
|
77
|
+
: {label: 'Site theme', value: String(cfg.theme || '-')},
|
|
78
|
+
{label: 'Admin theme', value: String(cfg.adminTheme || '-')},
|
|
79
|
+
{label: 'Element overrides', value: `${on} on${overrides.length - on ? `, ${overrides.length - on} off` : ''}`}
|
|
80
|
+
],
|
|
81
|
+
items: problems.slice(0, 8).map(p => ({text: `${p.where}: ${p.id}`, meta: 'no longer available - showing its base', tone: 'danger'})),
|
|
82
|
+
empty: 'Every theme in use is available.'
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
35
87
|
export async function themeRoutes(fastify, opts = {}) {
|
|
36
88
|
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
37
89
|
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
@@ -41,6 +93,9 @@ export async function themeRoutes(fastify, opts = {}) {
|
|
|
41
93
|
|
|
42
94
|
fastify.get('/theme', canRead, async () => loadThemeConfig());
|
|
43
95
|
|
|
96
|
+
fastify.get('/theme/count', canRead, async () =>
|
|
97
|
+
themeBadge(await loadThemeConfig(), listThemeIds(), listCustomThemes().map(t => t.id)));
|
|
98
|
+
|
|
44
99
|
// The editor's theme picker and token picker build themselves from this
|
|
45
100
|
// rather than from a hardcoded list that would drift from the CSS.
|
|
46
101
|
fastify.get('/theme/catalog', canRead, async () => ({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Users API
|
|
3
3
|
* GET /api/users - list all users (admin, manager)
|
|
4
|
+
* GET /api/users/_count - the sidebar badge (ids are uuids - nothing can shadow it)
|
|
4
5
|
* GET /api/users/:id - single user (admin, manager, or self)
|
|
5
6
|
* POST /api/users - create user (admin, manager - manager cannot create admin)
|
|
6
7
|
* PUT /api/users/:id - update user (admin, manager - manager cannot edit admin)
|
|
@@ -10,6 +11,45 @@ import {authenticate, canManageUser, requirePermission} from '../../middleware/a
|
|
|
10
11
|
import {getPermissionsFor} from '../../services/roles.js';
|
|
11
12
|
import {createUser, deleteUser, getUserById, listUsers, updateUser} from '../../services/users.js';
|
|
12
13
|
import {getProfile, listProfiles, updateProfile} from '../../services/userProfiles.js';
|
|
14
|
+
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The sidebar badge on Users (its countUrl, GET /api/users/_count). The number
|
|
18
|
+
* stays what the sidebar has always shown there - the users this reader can
|
|
19
|
+
* see - and the popover says who is inactive, who has never signed in, and
|
|
20
|
+
* who signed in last. Plain text throughout (the popover uses textContent);
|
|
21
|
+
* never anything from the password or tokens.
|
|
22
|
+
*
|
|
23
|
+
* @param {object[]} users - as the list route returns them (password stripped)
|
|
24
|
+
* @param {number} [now]
|
|
25
|
+
*/
|
|
26
|
+
export function usersBadge(users = [], now = Date.now()) {
|
|
27
|
+
const list = users || [];
|
|
28
|
+
const inactive = list.filter(u => u.isActive === false);
|
|
29
|
+
const never = list.filter(u => !u.lastLogin);
|
|
30
|
+
const week = list.filter(u => u.lastLogin && now - new Date(u.lastLogin).getTime() < 7 * 86_400_000);
|
|
31
|
+
const recent = list.filter(u => u.lastLogin)
|
|
32
|
+
.sort((a, b) => String(b.lastLogin).localeCompare(String(a.lastLogin)));
|
|
33
|
+
return {
|
|
34
|
+
count: list.length,
|
|
35
|
+
label: `${plural(list.length, 'user')}${inactive.length ? `, ${inactive.length} inactive` : ''}`,
|
|
36
|
+
tone: 'info',
|
|
37
|
+
details: {
|
|
38
|
+
title: 'Users',
|
|
39
|
+
rows: [
|
|
40
|
+
{label: 'Active', value: String(list.length - inactive.length)},
|
|
41
|
+
{label: 'Inactive', value: String(inactive.length)},
|
|
42
|
+
{label: 'Signed in this week', value: String(week.length)},
|
|
43
|
+
{label: 'Never signed in', value: String(never.length)}
|
|
44
|
+
],
|
|
45
|
+
items: recent.slice(0, 6).map(u => ({
|
|
46
|
+
text: clip(u.name || u.email || 'User'),
|
|
47
|
+
meta: [u.role, `signed in ${ago(u.lastLogin, now)}`].filter(Boolean).join(' · ')
|
|
48
|
+
})),
|
|
49
|
+
empty: 'Nobody has signed in yet.'
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
13
53
|
|
|
14
54
|
export async function usersRoutes(fastify) {
|
|
15
55
|
const canRead = [authenticate, requirePermission('users', 'read')];
|
|
@@ -27,6 +67,12 @@ export async function usersRoutes(fastify) {
|
|
|
27
67
|
.map(u => ({...u, profile: profiles.get(u.id) || {}}));
|
|
28
68
|
});
|
|
29
69
|
|
|
70
|
+
// The sidebar badge - the same users the list shows this reader. Before /users/:id.
|
|
71
|
+
fastify.get('/users/_count', {preHandler: canRead}, async (request) => {
|
|
72
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
73
|
+
return usersBadge((await listUsers()).filter(u => canSeeArtefact(request.user, u)));
|
|
74
|
+
});
|
|
75
|
+
|
|
30
76
|
// Get single user (admin, manager, or the user themselves)
|
|
31
77
|
fastify.get('/users/:id', { preHandler: [authenticate] }, async (request, reply) => {
|
|
32
78
|
const { id } = request.params;
|
|
@@ -119,7 +165,13 @@ export async function usersRoutes(fastify) {
|
|
|
119
165
|
|
|
120
166
|
// Strip profile from core updates and handle separately
|
|
121
167
|
const {profile, ...coreUpdates} = updates;
|
|
122
|
-
|
|
168
|
+
let user;
|
|
169
|
+
try {
|
|
170
|
+
user = await updateUser(id, coreUpdates);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err.code === 'EMAIL_TAKEN') return reply.status(409).send({error: err.message});
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
123
175
|
|
|
124
176
|
if (profile && typeof profile === 'object') {
|
|
125
177
|
await updateProfile(id, profile);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Views API
|
|
3
3
|
*
|
|
4
4
|
* Admin endpoints (authenticated + views permission):
|
|
5
|
-
* GET /views - List all view configs
|
|
5
|
+
* GET /views - List all view configs (?sources=1 adds `sourceInfo` per view)
|
|
6
|
+
* GET /views/_count - The sidebar badge (the underscore is outside the slug alphabet)
|
|
6
7
|
* POST /views - Create view config
|
|
7
8
|
* GET /views/:slug - Get view config
|
|
8
9
|
* PUT /views/:slug - Update view config
|
|
@@ -13,11 +14,17 @@
|
|
|
13
14
|
* Public endpoint (role-checked per view access config):
|
|
14
15
|
* GET /views/:slug/public - Execute view publicly
|
|
15
16
|
*/
|
|
16
|
-
import {createView, deleteView, executeView, getView, listViews, updateView} from '../../services/views.js';
|
|
17
|
+
import {createView, deleteView, executeView, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
|
|
17
18
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
18
19
|
import {getRoleLevel} from '../../services/roles.js';
|
|
19
20
|
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
20
21
|
|
|
22
|
+
/** Every collection by slug - a missing one is a view whose source was deleted. */
|
|
23
|
+
async function collectionsBySlug() {
|
|
24
|
+
const {listCollections} = await import('../../services/collections.js');
|
|
25
|
+
return new Map((await listCollections().catch(() => [])).map(c => [c.slug, c]));
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
export async function viewsRoutes(fastify) {
|
|
22
29
|
const canRead = {preHandler: [authenticate, requirePermission('views', 'read')]};
|
|
23
30
|
const canCreate = {preHandler: [authenticate, requirePermission('views', 'create')]};
|
|
@@ -33,7 +40,18 @@ export async function viewsRoutes(fastify) {
|
|
|
33
40
|
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
34
41
|
const all = await listViews();
|
|
35
42
|
// listViews returns full records with meta inline - filter directly
|
|
36
|
-
|
|
43
|
+
const visible = all.filter(v => canSeeArtefact(request.user, v));
|
|
44
|
+
if (request.query?.sources !== '1') return visible;
|
|
45
|
+
// The Views screen: what each view reads from - whether that collection still
|
|
46
|
+
// exists, its title and its storage (MongoDB runs native aggregation, file
|
|
47
|
+
// collections the JS pipeline). A views user need not hold collections.read.
|
|
48
|
+
const bySlug = await collectionsBySlug();
|
|
49
|
+
return visible.map(v => {
|
|
50
|
+
const c = bySlug.get(v.pipeline?.source);
|
|
51
|
+
return {...v, sourceInfo: c
|
|
52
|
+
? {exists: true, title: c.title || c.slug, adapter: c.storage?.adapter || 'file'}
|
|
53
|
+
: {exists: false}};
|
|
54
|
+
});
|
|
37
55
|
} catch (err) {
|
|
38
56
|
return reply.status(503).send({ error: err.message });
|
|
39
57
|
}
|
|
@@ -49,6 +67,12 @@ export async function viewsRoutes(fastify) {
|
|
|
49
67
|
}
|
|
50
68
|
});
|
|
51
69
|
|
|
70
|
+
fastify.get('/views/_count', canRead, async (request) => {
|
|
71
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
72
|
+
const visible = (await listViews()).filter(v => canSeeArtefact(request.user, v));
|
|
73
|
+
return viewsBadge(visible, new Set((await collectionsBySlug()).keys()));
|
|
74
|
+
});
|
|
75
|
+
|
|
52
76
|
// Static sub-routes must be declared before the parameterised :slug route
|
|
53
77
|
// so Fastify's radix router gives them priority.
|
|
54
78
|
fastify.get('/views/collection/:collectionSlug', canRead, async (request, reply) => {
|
package/server/server.js
CHANGED
|
@@ -440,6 +440,7 @@ const {menusRoutes} = await import('./routes/api/menus.js');
|
|
|
440
440
|
const {contextMenusRoutes} = await import('./routes/api/context-menus.js');
|
|
441
441
|
const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
|
|
442
442
|
const {projectsRoutes} = await import('./routes/api/projects.js');
|
|
443
|
+
const {rolesRoutes} = await import('./routes/api/roles.js');
|
|
443
444
|
const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
|
|
444
445
|
const {apiEndpointsRoutes} = await import('./routes/api/api-endpoints.js');
|
|
445
446
|
const {endpointsPublicRoutes} = await import('./routes/api/endpoints-public.js');
|
|
@@ -475,6 +476,7 @@ await app.register(contextMenusRoutes, {prefix: '/api'});
|
|
|
475
476
|
await app.register(themeRoutes, {prefix: '/api'});
|
|
476
477
|
await app.register(menuLocationsRoutes, {prefix: '/api'});
|
|
477
478
|
await app.register(projectsRoutes, {prefix: '/api'});
|
|
479
|
+
await app.register(rolesRoutes, {prefix: '/api'});
|
|
478
480
|
await app.register(apiTokensRoutes, {prefix: '/api'});
|
|
479
481
|
await app.register(apiEndpointsRoutes, {prefix: '/api'});
|
|
480
482
|
await app.register(endpointsPublicRoutes, {prefix: '/api'});
|
|
@@ -528,7 +530,32 @@ registerSidebarItem({item: {source: 'Built in', text: 'Contacts', url: '#/contac
|
|
|
528
530
|
// Manage projects lives in the menu (the Projects folder, sidebar-migration.js);
|
|
529
531
|
// registering it here only adds its badge - the renderer merges a registered
|
|
530
532
|
// item into the menu node with the same url rather than adding a second one.
|
|
531
|
-
registerSidebarItem({folder: 'projects', item: {source: 'Built in', text: 'Manage projects', url: '#/projects', icon: 'folder', permission: 'projects', countUrl: '/api/projects/_count'}});
|
|
533
|
+
registerSidebarItem({folder: 'projects', item: {source: 'Built in', text: 'Manage projects', url: '#/projects', icon: 'folder', permission: 'projects', countUrl: '/api/projects/_count', inventory: true}});
|
|
534
|
+
// Content and Data: the same - each merges into its menu entry and brings its badge
|
|
535
|
+
// (which keeps the number the sidebar always showed there; the popover says more).
|
|
536
|
+
// `inventory`: a how-many count, left out of a closed folder's total (sidebar-renderer).
|
|
537
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Pages', url: '#/pages', icon: 'file-text', permission: 'pages', countUrl: '/api/pages/_count', inventory: true}});
|
|
538
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Media', url: '#/media', icon: 'image', permission: 'media', countUrl: '/api/media/_count', inventory: true}});
|
|
539
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Menus', url: '#/menus', icon: 'menu', permission: 'menus', countUrl: '/api/menus/_count', inventory: true}});
|
|
540
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Context Menus', url: '#/context-menus', icon: 'menu', permission: 'context-menus', countUrl: '/api/context-menus/_count', inventory: true}});
|
|
541
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Theme', url: '#/theme', icon: 'droplet', permission: 'theme', countUrl: '/api/theme/count', inventory: true}});
|
|
542
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Collections', url: '#/collections', icon: 'database', permission: 'collections', countUrl: '/api/collections/_count', inventory: true}});
|
|
543
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections', countUrl: '/api/forms/_count', inventory: true}});
|
|
544
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Views', url: '#/views', icon: 'eye', permission: 'views', countUrl: '/api/views/_count', inventory: true}});
|
|
545
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Actions', url: '#/actions', icon: 'zap', permission: 'actions', countUrl: '/api/actions/_count', inventory: true}});
|
|
546
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints', countUrl: '/api/api-endpoints/_count', inventory: true}});
|
|
547
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages', countUrl: '/api/blocks/_count', inventory: true}});
|
|
548
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Components', url: '#/components', icon: 'component', permission: 'components', countUrl: '/api/components/_count', inventory: true}});
|
|
549
|
+
// System. Notifications, Site Settings, Layouts and API Tokens are signals (unread, something quietly
|
|
550
|
+
// wrong, pages on a missing layout, tokens expiring), so not `inventory`.
|
|
551
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Notifications', url: '#/system/notifications', icon: 'bell', permission: 'notifications', countUrl: '/api/system/notifications/_count'}});
|
|
552
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Roles', url: '#/roles', icon: 'shield', permission: 'plugins', countUrl: '/api/roles/_count', inventory: true}});
|
|
553
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Users', url: '#/users', icon: 'users', permission: 'users', countUrl: '/api/users/_count', inventory: true}});
|
|
554
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings', countUrl: '/api/settings/_count'}});
|
|
555
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts', countUrl: '/api/layouts/_count'}});
|
|
556
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
|
|
557
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
|
|
558
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
|
|
532
559
|
|
|
533
560
|
await registerPlugins(app);
|
|
534
561
|
|
|
@@ -475,3 +475,44 @@ export function toClientPayload(menu) {
|
|
|
475
475
|
items: menu.items || []
|
|
476
476
|
};
|
|
477
477
|
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The sidebar badge on Context Menus (its countUrl, GET /api/context-menus/_count):
|
|
481
|
+
* how many menus there are, with the ones switched off and the ones whose
|
|
482
|
+
* binding matches nothing called out - an enabled menu that never appears is
|
|
483
|
+
* the one thing here worth a second look. Plain text throughout: the popover
|
|
484
|
+
* sets it with textContent.
|
|
485
|
+
*
|
|
486
|
+
* @param {object[]} menus - as listContextMenus returns them
|
|
487
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
488
|
+
*/
|
|
489
|
+
export function contextMenusBadge(menus = []) {
|
|
490
|
+
const list = [...menus].sort((a, b) => String(a.name || a.slug).localeCompare(String(b.name || b.slug)));
|
|
491
|
+
const off = list.filter(m => m.enabled === false);
|
|
492
|
+
const never = list.filter(m => m.enabled !== false && m.binding
|
|
493
|
+
&& !(m.binding.projects || []).length && !(m.binding.pages || []).length);
|
|
494
|
+
const siteWide = list.filter(m => m.enabled !== false && !m.binding).length;
|
|
495
|
+
const n = list.length;
|
|
496
|
+
const bits = [off.length && `${off.length} off`, never.length && `${never.length} never matching`].filter(Boolean);
|
|
497
|
+
return {
|
|
498
|
+
count: n,
|
|
499
|
+
label: `${n} context menu${n === 1 ? '' : 's'}${bits.length ? `, ${bits.join(', ')}` : ''}`,
|
|
500
|
+
tone: never.length ? 'attention' : 'info',
|
|
501
|
+
details: {
|
|
502
|
+
title: 'Context Menus',
|
|
503
|
+
rows: [
|
|
504
|
+
{label: 'On', value: String(n - off.length)},
|
|
505
|
+
{label: 'Off', value: String(off.length)},
|
|
506
|
+
{label: 'Site-wide', value: String(siteWide)},
|
|
507
|
+
{label: 'Never matching', value: String(never.length), ...(never.length && {tone: 'danger'})}
|
|
508
|
+
],
|
|
509
|
+
items: list.slice(0, 8).map(m => ({
|
|
510
|
+
text: String(m.name || m.slug),
|
|
511
|
+
meta: [m.selector || 'no target', `${m.itemsCount ?? 0} item${m.itemsCount === 1 ? '' : 's'}`,
|
|
512
|
+
m.enabled === false ? 'off' : '', never.includes(m) ? 'never matches' : ''].filter(Boolean).join(' · '),
|
|
513
|
+
...(never.includes(m) && {tone: 'danger'})
|
|
514
|
+
})),
|
|
515
|
+
empty: 'No context menus yet.'
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
@@ -266,6 +266,13 @@ async function loadConfigDefaults(name) {
|
|
|
266
266
|
if (_configDefaultsCache.has(name)) return _configDefaultsCache.get(name);
|
|
267
267
|
const configJsPath = path.join(PLUGINS_DIR, name, 'config.js');
|
|
268
268
|
let defaults = {};
|
|
269
|
+
// No config.js is "no defaults", not a failure: the Marketplace asks for every
|
|
270
|
+
// installed plugin's settings (enabled or not), and recording a load failure for
|
|
271
|
+
// one without the file would put it on the health report for being looked at.
|
|
272
|
+
try { await fs.access(configJsPath); } catch {
|
|
273
|
+
_configDefaultsCache.set(name, defaults);
|
|
274
|
+
return defaults;
|
|
275
|
+
}
|
|
269
276
|
try {
|
|
270
277
|
const {default: exported} = await import(configJsPath);
|
|
271
278
|
defaults = exported || {};
|
package/server/services/roles.js
CHANGED
|
@@ -26,6 +26,9 @@ const USERS_DIR = path.resolve(path.dirname(COLLECTIONS_DIR), 'users');
|
|
|
26
26
|
/** Base role slugs that should always exist */
|
|
27
27
|
const BASE_ROLE_NAMES = ['super-admin', 'admin', 'user'];
|
|
28
28
|
|
|
29
|
+
/** The seeded roles every site relies on - never deletable, whatever route is asked. */
|
|
30
|
+
export const isBaseRole = (name) => BASE_ROLE_NAMES.includes(name);
|
|
31
|
+
|
|
29
32
|
/** Role slugs that were seeded by the old 4-role system - used to detect legacy data */
|
|
30
33
|
const DEFUNCT_ROLE_NAMES = ['manager', 'editor', 'subscriber'];
|
|
31
34
|
|