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.
Files changed (116) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/app.js +4 -4
  3. package/admin/js/lib/actions-arrange.js +1 -0
  4. package/admin/js/lib/api-endpoints-arrange.js +1 -0
  5. package/admin/js/lib/api-tokens-arrange.js +3 -0
  6. package/admin/js/lib/blocks-arrange.js +1 -0
  7. package/admin/js/lib/collections-arrange.js +1 -0
  8. package/admin/js/lib/components-arrange.js +1 -0
  9. package/admin/js/lib/context-menus-arrange.js +1 -0
  10. package/admin/js/lib/effects-arrange.js +3 -0
  11. package/admin/js/lib/forms-arrange.js +1 -0
  12. package/admin/js/lib/layouts-arrange.js +1 -0
  13. package/admin/js/lib/media-arrange.js +1 -0
  14. package/admin/js/lib/menus-arrange.js +1 -0
  15. package/admin/js/lib/my-profile-arrange.js +1 -0
  16. package/admin/js/lib/notifications-arrange.js +1 -0
  17. package/admin/js/lib/pages-arrange.js +1 -0
  18. package/admin/js/lib/plugins-arrange.js +2 -0
  19. package/admin/js/lib/roles-arrange.js +1 -0
  20. package/admin/js/lib/search-settings-arrange.js +1 -0
  21. package/admin/js/lib/sidebar-renderer.js +3 -3
  22. package/admin/js/lib/site-settings-arrange.js +1 -0
  23. package/admin/js/lib/theme-settings-arrange.js +1 -0
  24. package/admin/js/lib/users-arrange.js +1 -0
  25. package/admin/js/lib/views-arrange.js +1 -0
  26. package/admin/js/templates/actions-list.html +73 -14
  27. package/admin/js/templates/api-endpoints.html +63 -10
  28. package/admin/js/templates/api-tokens.html +83 -10
  29. package/admin/js/templates/blocks.html +76 -15
  30. package/admin/js/templates/collections.html +108 -52
  31. package/admin/js/templates/components.html +82 -15
  32. package/admin/js/templates/context-menus.html +55 -13
  33. package/admin/js/templates/effects.html +155 -72
  34. package/admin/js/templates/forms.html +67 -14
  35. package/admin/js/templates/layouts.html +126 -43
  36. package/admin/js/templates/media.html +99 -13
  37. package/admin/js/templates/menus.html +57 -11
  38. package/admin/js/templates/my-profile.html +100 -12
  39. package/admin/js/templates/notifications.html +70 -10
  40. package/admin/js/templates/pages.html +78 -23
  41. package/admin/js/templates/plugins.html +100 -28
  42. package/admin/js/templates/roles.html +78 -7
  43. package/admin/js/templates/search.html +193 -216
  44. package/admin/js/templates/settings.html +404 -472
  45. package/admin/js/templates/theme.html +164 -132
  46. package/admin/js/templates/users.html +69 -9
  47. package/admin/js/templates/views-list.html +63 -9
  48. package/admin/js/views/actions-list.js +37 -1
  49. package/admin/js/views/api-endpoints.js +11 -7
  50. package/admin/js/views/api-tokens.js +62 -8
  51. package/admin/js/views/blocks.js +34 -5
  52. package/admin/js/views/collections.js +36 -1
  53. package/admin/js/views/components.js +27 -5
  54. package/admin/js/views/context-menus.js +10 -5
  55. package/admin/js/views/effects.js +31 -1
  56. package/admin/js/views/forms.js +57 -1
  57. package/admin/js/views/layouts.js +81 -1
  58. package/admin/js/views/media.js +40 -1
  59. package/admin/js/views/menus.js +11 -5
  60. package/admin/js/views/my-profile.js +1 -1
  61. package/admin/js/views/notifications.js +18 -1
  62. package/admin/js/views/pages.js +28 -8
  63. package/admin/js/views/plugins.js +97 -39
  64. package/admin/js/views/roles.js +49 -4
  65. package/admin/js/views/search.js +7 -1
  66. package/admin/js/views/settings.js +13 -3
  67. package/admin/js/views/theme.js +55 -1
  68. package/admin/js/views/users.js +13 -4
  69. package/admin/js/views/views-list.js +11 -1
  70. package/package.json +1 -1
  71. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  72. package/plugins/_lib/admin/mail/contacts.js +4 -1
  73. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  74. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  75. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  76. package/plugins/_lib/admin/mail/mail.css +1 -1
  77. package/plugins/_lib/admin/mail/mail.html +1 -1
  78. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  79. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  80. package/plugins/_lib/mail/readRoutes.js +43 -1
  81. package/plugins/_lib/mail/unreadBadge.js +42 -0
  82. package/plugins/free-tier.lock.json +5 -5
  83. package/plugins/mail-reader/CLAUDE.md +1 -0
  84. package/plugins/mail-reader/plugin.js +3 -1
  85. package/plugins/mail-reader/plugin.json +5 -4
  86. package/server/routes/api/actions.js +64 -2
  87. package/server/routes/api/api-endpoints.js +41 -0
  88. package/server/routes/api/api-tokens.js +56 -0
  89. package/server/routes/api/auth.js +7 -1
  90. package/server/routes/api/blocks.js +170 -8
  91. package/server/routes/api/collections.js +66 -2
  92. package/server/routes/api/components.js +46 -0
  93. package/server/routes/api/context-menus.js +8 -0
  94. package/server/routes/api/effects.js +68 -13
  95. package/server/routes/api/forms.js +123 -10
  96. package/server/routes/api/layouts.js +93 -2
  97. package/server/routes/api/media.js +56 -1
  98. package/server/routes/api/menus.js +53 -6
  99. package/server/routes/api/notifications.js +140 -14
  100. package/server/routes/api/pages.js +46 -0
  101. package/server/routes/api/plugins.js +135 -4
  102. package/server/routes/api/roles.js +111 -0
  103. package/server/routes/api/search.js +38 -4
  104. package/server/routes/api/settings.js +52 -0
  105. package/server/routes/api/theme.js +55 -0
  106. package/server/routes/api/users.js +53 -1
  107. package/server/routes/api/views.js +27 -3
  108. package/server/server.js +28 -1
  109. package/server/services/contextMenus.js +41 -0
  110. package/server/services/plugins.js +7 -0
  111. package/server/services/roles.js +3 -0
  112. package/server/services/search.js +58 -0
  113. package/server/services/users.js +14 -0
  114. package/server/services/views.js +38 -0
  115. package/admin/js/templates/plugin-marketplace.html +0 -29
  116. 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} from '../../middleware/auth.js';
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', {preHandler: [authenticate, requireAdmin]}, async (request) => {
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
- const user = await updateUser(id, coreUpdates);
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
- return all.filter(v => canSeeArtefact(request.user, v));
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 || {};
@@ -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