domma-cms 0.75.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 (66) hide show
  1. package/admin/js/app.js +4 -4
  2. package/admin/js/lib/api-tokens-arrange.js +3 -0
  3. package/admin/js/lib/effects-arrange.js +3 -0
  4. package/admin/js/lib/layouts-arrange.js +1 -0
  5. package/admin/js/lib/my-profile-arrange.js +1 -0
  6. package/admin/js/lib/notifications-arrange.js +1 -0
  7. package/admin/js/lib/plugins-arrange.js +2 -0
  8. package/admin/js/lib/roles-arrange.js +1 -0
  9. package/admin/js/lib/search-settings-arrange.js +1 -0
  10. package/admin/js/lib/sidebar-renderer.js +2 -2
  11. package/admin/js/lib/site-settings-arrange.js +1 -0
  12. package/admin/js/lib/users-arrange.js +1 -0
  13. package/admin/js/templates/api-tokens.html +83 -10
  14. package/admin/js/templates/effects.html +155 -72
  15. package/admin/js/templates/layouts.html +126 -43
  16. package/admin/js/templates/my-profile.html +100 -12
  17. package/admin/js/templates/notifications.html +70 -10
  18. package/admin/js/templates/plugins.html +100 -28
  19. package/admin/js/templates/roles.html +78 -7
  20. package/admin/js/templates/search.html +193 -216
  21. package/admin/js/templates/settings.html +404 -472
  22. package/admin/js/templates/users.html +69 -9
  23. package/admin/js/views/api-tokens.js +62 -8
  24. package/admin/js/views/effects.js +31 -1
  25. package/admin/js/views/layouts.js +81 -1
  26. package/admin/js/views/my-profile.js +1 -1
  27. package/admin/js/views/notifications.js +18 -1
  28. package/admin/js/views/plugins.js +97 -39
  29. package/admin/js/views/roles.js +49 -4
  30. package/admin/js/views/search.js +7 -1
  31. package/admin/js/views/settings.js +13 -3
  32. package/admin/js/views/users.js +13 -4
  33. package/package.json +1 -1
  34. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  35. package/plugins/_lib/admin/mail/contacts.js +4 -1
  36. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  37. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  38. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  39. package/plugins/_lib/admin/mail/mail.css +1 -1
  40. package/plugins/_lib/admin/mail/mail.html +1 -1
  41. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  42. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  43. package/plugins/_lib/mail/readRoutes.js +43 -1
  44. package/plugins/_lib/mail/unreadBadge.js +42 -0
  45. package/plugins/free-tier.lock.json +5 -5
  46. package/plugins/mail-reader/CLAUDE.md +1 -0
  47. package/plugins/mail-reader/plugin.js +3 -1
  48. package/plugins/mail-reader/plugin.json +5 -4
  49. package/server/routes/api/api-tokens.js +56 -0
  50. package/server/routes/api/auth.js +7 -1
  51. package/server/routes/api/collections.js +13 -1
  52. package/server/routes/api/effects.js +68 -13
  53. package/server/routes/api/layouts.js +93 -2
  54. package/server/routes/api/notifications.js +140 -14
  55. package/server/routes/api/plugins.js +135 -4
  56. package/server/routes/api/roles.js +111 -0
  57. package/server/routes/api/search.js +38 -4
  58. package/server/routes/api/settings.js +52 -0
  59. package/server/routes/api/users.js +53 -1
  60. package/server/server.js +12 -0
  61. package/server/services/plugins.js +7 -0
  62. package/server/services/roles.js +3 -0
  63. package/server/services/search.js +58 -0
  64. package/server/services/users.js +14 -0
  65. package/admin/js/templates/plugin-marketplace.html +0 -29
  66. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -3,7 +3,11 @@
3
3
  * POST /api/system/notifications - Manager push (requireManager)
4
4
  * GET /api/system/notifications - List active notifications (admin/manager)
5
5
  * GET /api/system/notifications/unread-count - Unread count for bell badge
6
+ * GET /api/system/notifications/_count - The sidebar badge (unread, with a popover)
6
7
  * POST /api/system/notifications/:id/read - Mark read (idempotent)
8
+ * POST /api/system/notifications/:id/unread - Mark unread again (idempotent)
9
+ * POST /api/system/notifications/dismiss-all - Dismiss every active one, for this user
10
+ * POST /api/system/notifications/read-all - Mark every active one read, for this user
7
11
  * POST /api/system/notifications/:id/dismiss - Mark dismissed
8
12
  * DELETE /api/system/notifications/:id - Hard delete (admin only)
9
13
  */
@@ -12,6 +16,7 @@ import {
12
16
  requirePermission as defaultRequirePermission
13
17
  } from '../../middleware/auth.js';
14
18
  import { requireManager } from '../../middleware/managerAuth.js';
19
+ import { randomUUID } from 'crypto';
15
20
  import { getAdapter } from '../../services/adapterRegistry.js';
16
21
 
17
22
  const SLUG = 'notifications';
@@ -45,6 +50,18 @@ function notificationData(entry, log) {
45
50
  return null;
46
51
  }
47
52
 
53
+ /**
54
+ * Write a notification's payload back as a whole entry - `{id, data, meta}`.
55
+ *
56
+ * The adapter's update() REPLACES the stored entry with what it is given, and
57
+ * until 0.76.0 these routes gave it the bare payload: marking a notification
58
+ * read turned it into a flat record with no id and no `data`, which the list
59
+ * then skipped as malformed - read once, gone for good.
60
+ */
61
+ async function saveData(adapter, entry, data) {
62
+ return adapter.update(SLUG, entry.id, {...entry, data, meta: {...(entry.meta || {}), updatedAt: new Date().toISOString()}});
63
+ }
64
+
48
65
  /**
49
66
  * Is this notification live for this user right now: not expired, not dismissed.
50
67
  *
@@ -58,6 +75,43 @@ function isActiveFor(data, userId, now) {
58
75
  return !(Array.isArray(data.dismissedBy) && data.dismissedBy.includes(userId));
59
76
  }
60
77
 
78
+ /**
79
+ * The sidebar badge on Notifications (its countUrl): the unread ones - the same
80
+ * number the sidebar always showed there - red while any of them is critical.
81
+ * Plain text throughout: the popover sets it with textContent.
82
+ *
83
+ * @param {object[]} active - this user's active entries, each with `unread`
84
+ * @returns {{count: number, label: string, tone: string, details: object}}
85
+ */
86
+ export function notificationsBadge(active = []) {
87
+ const unread = active.filter(e => e.unread)
88
+ .sort((a, b) => String(b.data.createdAt || '').localeCompare(String(a.data.createdAt || '')));
89
+ const sev = (e) => e.data?.severity || 'info';
90
+ const critical = unread.filter(e => sev(e) === 'critical').length;
91
+ const warning = unread.filter(e => sev(e) === 'warning').length;
92
+ const n = unread.length;
93
+ return {
94
+ count: n,
95
+ label: n ? `${n} unread notification${n === 1 ? '' : 's'}${critical ? `, ${critical} critical` : ''}` : 'No unread notifications',
96
+ tone: critical ? 'danger' : 'info',
97
+ details: {
98
+ title: 'Notifications',
99
+ rows: [
100
+ {label: 'Unread', value: String(n)},
101
+ {label: 'Critical', value: String(critical), ...(critical && {tone: 'danger'})},
102
+ {label: 'Warnings', value: String(warning)},
103
+ {label: 'In the inbox', value: String(active.length)}
104
+ ],
105
+ items: unread.slice(0, 6).map(e => ({
106
+ text: String(e.data.title || '(no title)').slice(0, 80),
107
+ meta: [sev(e), String(e.data.createdAt || '').slice(0, 10)].filter(Boolean).join(' · '),
108
+ ...(sev(e) === 'critical' && {tone: 'danger'})
109
+ })),
110
+ empty: 'Nothing unread.'
111
+ }
112
+ };
113
+ }
114
+
61
115
  export async function notificationsRoutes(fastify, opts = {}) {
62
116
  // Injectable so tests can supply no-ops rather than a seeded roles cache -
63
117
  // the same DI pattern as menus.js, projects.js and context-menus.js.
@@ -94,17 +148,25 @@ export async function notificationsRoutes(fastify, opts = {}) {
94
148
  }
95
149
  }
96
150
 
151
+ // A whole entry, {id, data, meta}: the adapter stores what it is given, and
152
+ // before 0.76.0 this handed it the bare payload - no id, no `data` - so the
153
+ // list skipped every pushed notification as malformed and none ever showed.
97
154
  const adapter = await getAdapter(SLUG);
155
+ const now = new Date().toISOString();
98
156
  const entry = await adapter.insert(SLUG, {
99
- title: title.trim(),
100
- body: body.trim(),
101
- severity,
102
- source: 'manager',
103
- link: link || null,
104
- createdAt: new Date().toISOString(),
105
- expiresAt: expiresAt || null,
106
- readBy: [],
107
- dismissedBy: []
157
+ id: randomUUID(),
158
+ data: {
159
+ title: title.trim(),
160
+ body: body.trim(),
161
+ severity,
162
+ source: 'manager',
163
+ link: link || null,
164
+ createdAt: now,
165
+ expiresAt: expiresAt || null,
166
+ readBy: [],
167
+ dismissedBy: []
168
+ },
169
+ meta: {createdAt: now, updatedAt: now, source: 'manager'}
108
170
  });
109
171
 
110
172
  return reply.code(201).send(entry);
@@ -147,6 +209,73 @@ export async function notificationsRoutes(fastify, opts = {}) {
147
209
  return { count };
148
210
  });
149
211
 
212
+ /** This user's active notifications, newest first, each flagged `unread`. */
213
+ async function activeFor(userId) {
214
+ const now = new Date().toISOString();
215
+ const adapter = await getAdapter(SLUG);
216
+ return (await adapter.all(SLUG))
217
+ .filter(entry => {
218
+ const d = notificationData(entry, fastify.log);
219
+ return !!d && isActiveFor(d, userId, now);
220
+ })
221
+ .map(entry => ({...entry, unread: !Array.isArray(entry.data.readBy) || !entry.data.readBy.includes(userId)}));
222
+ }
223
+
224
+ // --- Sidebar badge (static path: GET has no /:id route to shadow) ---
225
+ fastify.get('/system/notifications/_count', canRead, async (request) => notificationsBadge(await activeFor(request.user.id)));
226
+
227
+ // --- Dismiss everything active, for this user ---
228
+ fastify.post('/system/notifications/dismiss-all', canRead, async (request) => {
229
+ const userId = request.user.id;
230
+ const adapter = await getAdapter(SLUG);
231
+ let dismissed = 0;
232
+ for (const entry of await activeFor(userId)) {
233
+ const {unread: _u, ...stored} = entry;
234
+ const data = stored.data;
235
+ const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
236
+ const readBy = Array.isArray(data.readBy) ? data.readBy : [];
237
+ await saveData(adapter, stored, {
238
+ ...data,
239
+ dismissedBy: [...dismissedBy, userId],
240
+ readBy: readBy.includes(userId) ? readBy : [...readBy, userId]
241
+ });
242
+ dismissed++;
243
+ }
244
+ return {ok: true, dismissed};
245
+ });
246
+
247
+ // --- Mark everything active read, for this user ---
248
+ // One request, one pass: the screen used to fire a mark-read per notification
249
+ // at once, and on file storage each read-modify-write overwrote the others -
250
+ // six marked, one stuck.
251
+ fastify.post('/system/notifications/read-all', canRead, async (request) => {
252
+ const userId = request.user.id;
253
+ const adapter = await getAdapter(SLUG);
254
+ let marked = 0;
255
+ for (const entry of await activeFor(userId)) {
256
+ if (!entry.unread) continue;
257
+ const {unread: _u, ...stored} = entry;
258
+ const readBy = Array.isArray(stored.data.readBy) ? stored.data.readBy : [];
259
+ await saveData(adapter, stored, {...stored.data, readBy: [...readBy, userId]});
260
+ marked++;
261
+ }
262
+ return {ok: true, marked};
263
+ });
264
+
265
+ // --- Mark unread again (idempotent) ---
266
+ fastify.post('/system/notifications/:id/unread', canRead, async (request, reply) => {
267
+ const { id } = request.params;
268
+ const userId = request.user.id;
269
+ const adapter = await getAdapter(SLUG);
270
+ const entry = await adapter.get(SLUG, id);
271
+ const data = notificationData(entry, fastify.log);
272
+ if (!data) return reply.code(404).send({ error: 'Notification not found.' });
273
+ const readBy = Array.isArray(data.readBy) ? data.readBy : [];
274
+ if (!readBy.includes(userId)) return { ok: true };
275
+ const updated = await saveData(adapter, entry, {...data, readBy: readBy.filter(u => u !== userId)});
276
+ return { ok: true, entry: updated };
277
+ });
278
+
150
279
  // --- Mark read (idempotent) ---
151
280
  fastify.post('/system/notifications/:id/read', canRead, async (request, reply) => {
152
281
  const { id } = request.params;
@@ -161,10 +290,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
161
290
  const readBy = Array.isArray(data.readBy) ? data.readBy : [];
162
291
  if (readBy.includes(userId)) return { ok: true }; // already read - idempotent
163
292
 
164
- const updated = await adapter.update(SLUG, id, {
165
- ...data,
166
- readBy: [...readBy, userId]
167
- });
293
+ const updated = await saveData(adapter, entry, {...data, readBy: [...readBy, userId]});
168
294
 
169
295
  return { ok: true, entry: updated };
170
296
  });
@@ -182,7 +308,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
182
308
  const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
183
309
  const readBy = Array.isArray(data.readBy) ? data.readBy : [];
184
310
 
185
- const updated = await adapter.update(SLUG, id, {
311
+ const updated = await saveData(adapter, entry, {
186
312
  ...data,
187
313
  dismissedBy: dismissedBy.includes(userId) ? dismissedBy : [...dismissedBy, userId],
188
314
  readBy: readBy.includes(userId) ? readBy : [...readBy, userId] // dismissing also marks read
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Plugins API
3
3
  * GET /api/plugins - list all discovered plugins + state (admin only)
4
+ * GET /api/plugins/_count - the sidebar badge (`_` is outside the slug alphabet)
4
5
  * POST /api/plugins/scaffold - create a new plugin from plugins/_template (admin only)
5
6
  * PUT /api/plugins/:name - enable/disable, update settings (admin only)
6
7
  * GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
@@ -14,6 +15,7 @@ import {
14
15
  getAdminPluginConfig,
15
16
  getEntitlementStates,
16
17
  getLoadedPlugins,
18
+ getPluginSettings,
17
19
  getPluginStates,
18
20
  isCorePlugin,
19
21
  pluginLicence,
@@ -26,7 +28,8 @@ import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from
26
28
  import {getConfig} from '../../config.js';
27
29
  import path from 'path';
28
30
  import {existsSync, readFileSync} from 'node:fs';
29
- import {installPlugin} from '../../services/pluginInstaller.js';
31
+ import {compareVersions, installPlugin} from '../../services/pluginInstaller.js';
32
+ import {fetchCatalogue, isManaged} from '../../services/managerClient.js';
30
33
  import {packEnvelope, readEnvelope, readManifestFromTarball, tarDirectory} from '../../services/pluginEnvelope.js';
31
34
 
32
35
  /**
@@ -95,6 +98,111 @@ function autoRestartEnabled() {
95
98
  }
96
99
  }
97
100
 
101
+ /** Entitlement states that stop a plugin running (the loader refuses them). */
102
+ const BLOCKING = new Set(['expired', 'unlicensed']);
103
+
104
+ /**
105
+ * The sidebar badge on Plugins (its countUrl, GET /api/plugins/_count). The
106
+ * number stays what the sidebar always showed there - how many are installed -
107
+ * and the tone and popover say what needs doing: red while a licensed plugin
108
+ * that is switched on is not running, amber for an update or a licence in its
109
+ * grace period. Plain text throughout; the popover sets it with textContent.
110
+ *
111
+ * @param {{name, displayName, version, enabled, core, entitlement}[]} plugins
112
+ * @param {Map<string, string>} [latest] - slug -> newest catalogue version (managed sites only)
113
+ * @returns {{count, label, tone, details}}
114
+ */
115
+ export function pluginsBadge(plugins = [], latest = new Map()) {
116
+ const name = (p) => String(p.displayName || p.name);
117
+ const stopped = plugins.filter(p => p.enabled && BLOCKING.has(p.entitlement));
118
+ const grace = plugins.filter(p => p.entitlement === 'grace');
119
+ const updates = plugins.filter(p => latest.has(p.name) && compareVersions(latest.get(p.name), p.version) > 0);
120
+ const on = plugins.filter(p => p.enabled || p.core).length;
121
+ const n = plugins.length;
122
+ const extra = [
123
+ stopped.length && `${stopped.length} not running`,
124
+ updates.length && `${updates.length} update${updates.length === 1 ? '' : 's'}`
125
+ ].filter(Boolean).join(', ');
126
+ return {
127
+ count: n,
128
+ label: `${n} plugin${n === 1 ? '' : 's'} installed${extra ? `, ${extra}` : ''}`,
129
+ tone: stopped.length ? 'danger' : (updates.length || grace.length) ? 'attention' : 'info',
130
+ details: {
131
+ title: 'Plugins',
132
+ rows: [
133
+ {label: 'Switched on', value: String(on)},
134
+ {label: 'Switched off', value: String(n - on)},
135
+ ...(latest.size ? [{label: 'Updates available', value: String(updates.length)}] : []),
136
+ ...(stopped.length ? [{label: 'Not running (licence)', value: String(stopped.length), tone: 'danger'}] : [])
137
+ ],
138
+ items: [
139
+ ...stopped.map(p => ({text: name(p), meta: `not running - licence ${p.entitlement}`, tone: 'danger'})),
140
+ ...grace.map(p => ({text: name(p), meta: 'licence ended - running in its grace period, renew'})),
141
+ ...updates.map(p => ({text: name(p), meta: `update ${p.version} → ${latest.get(p.name)}`}))
142
+ ].slice(0, 8),
143
+ empty: 'Everything is running and up to date.'
144
+ }
145
+ };
146
+ }
147
+
148
+ /**
149
+ * The newest catalogue version of each plugin, for a managed site - kept ten
150
+ * minutes: the badge is asked for every minute from every admin tab, and each
151
+ * ask would otherwise be a round trip to the manager.
152
+ */
153
+ let _latest = {at: 0, value: new Map()};
154
+ async function latestVersions() {
155
+ if (!isManaged()) return new Map();
156
+ if (Date.now() - _latest.at < 10 * 60_000) return _latest.value;
157
+ const catalogue = await fetchCatalogue().catch(() => []);
158
+ _latest = {at: Date.now(), value: new Map((catalogue || []).map(e => [e.slug, e.version]))};
159
+ return _latest.value;
160
+ }
161
+
162
+ /**
163
+ * Plugin settings reach the admin browser (the Marketplace's Configure form), so
164
+ * a value whose key looks secret - a DVLA key, an SMTP password, a connection
165
+ * URI - is sent as SETTING_MASK instead. Saving sends the mask back for any
166
+ * secret left untouched, and restoreMasked() puts the stored value back; a
167
+ * mask with nothing stored behind it (a config.js default) is dropped, so the
168
+ * default still applies. Same rule as isSecretKey() in admin/js/lib/plugins-arrange.js.
169
+ */
170
+ export const SETTING_MASK = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022';
171
+ const SECRET_KEY = /pass(word)?|secret|token|api[-_]?key|private|credential|uri$|dsn/i;
172
+
173
+ /** @param {Set<string>} [secretKeys] - keys a plugin's settingsSchema marks `secret: true` */
174
+ export function maskSettings(value, key = '', secretKeys = new Set()) {
175
+ // An array's strings are secret when its key is (apiKeys: ['a', 'b']).
176
+ if (Array.isArray(value)) return value.map(v => maskSettings(v, key, secretKeys));
177
+ if (value && typeof value === 'object') {
178
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskSettings(v, k, secretKeys)]));
179
+ }
180
+ const secret = SECRET_KEY.test(key) || secretKeys.has(key);
181
+ return secret && typeof value === 'string' && value !== '' ? SETTING_MASK : value;
182
+ }
183
+
184
+ const schemaSecrets = (manifest) => new Set((Array.isArray(manifest.settingsSchema) ? manifest.settingsSchema : [])
185
+ .filter(f => f && f.secret === true && typeof f.key === 'string').map(f => f.key));
186
+
187
+ export function restoreMasked(incoming, stored) {
188
+ // Arrays by position: maskSettings masks inside them too ({accounts: [{password}]}),
189
+ // and returning the array as sent stored the mask over the real value.
190
+ if (Array.isArray(incoming)) {
191
+ const before = Array.isArray(stored) ? stored : [];
192
+ return incoming.map((v, i) => (v === SETTING_MASK ? (before[i] ?? '') : restoreMasked(v, before[i])));
193
+ }
194
+ if (incoming && typeof incoming === 'object') {
195
+ const out = {};
196
+ for (const [k, v] of Object.entries(incoming)) {
197
+ const before = stored && typeof stored === 'object' ? stored[k] : undefined;
198
+ if (v === SETTING_MASK) { if (before !== undefined) out[k] = before; continue; }
199
+ out[k] = restoreMasked(v, before);
200
+ }
201
+ return out;
202
+ }
203
+ return incoming;
204
+ }
205
+
98
206
  export async function pluginsRoutes(fastify, opts = {}) {
99
207
  const authenticate = opts.authenticate || defaultAuthenticate;
100
208
  const requirePermission = opts.requirePermission || defaultRequirePermission;
@@ -114,7 +222,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
114
222
  const loadedPlugins = getLoadedPlugins();
115
223
  const entitlementStates = getEntitlementStates();
116
224
 
117
- return manifests.map(manifest => ({
225
+ return Promise.all(manifests.map(async manifest => ({
118
226
  name: manifest.name,
119
227
  displayName: manifest.displayName || manifest.name,
120
228
  version: manifest.version || '1.0.0',
@@ -143,8 +251,30 @@ export async function pluginsRoutes(fastify, opts = {}) {
143
251
  // Enabled but displaced by another plugin that replaces it. Shown
144
252
  // so the screen can explain the inactivity instead of looking broken.
145
253
  supersededBy: loadedPlugins[manifest.name]?.supersededBy ?? null,
146
- settings: states[manifest.name]?.settings || {}
254
+ settings: maskSettings(states[manifest.name]?.settings || {}, '', schemaSecrets(manifest)),
255
+ // What Configure edits: an optional plugin.json `settingsSchema`
256
+ // ([{key, label, type: text|number|select|boolean, options, min,
257
+ // max, help}]), else the plugin's config.js defaults merged with the
258
+ // stored overrides (`effectiveSettings`) - so every plugin with a
259
+ // config.js can be configured, not only one that declared a schema
260
+ // (none ever had, so the old Settings button never appeared).
261
+ settingsSchema: Array.isArray(manifest.settingsSchema) ? manifest.settingsSchema : null,
262
+ effectiveSettings: maskSettings(await getPluginSettings(manifest.name).catch(() => ({})), '', schemaSecrets(manifest))
263
+ })));
264
+ });
265
+
266
+ // The sidebar badge. Registered with the other static /plugins/* routes;
267
+ // `_` is outside the plugin slug alphabet, so no plugin can shadow it.
268
+ fastify.get('/plugins/_count', canRead, async () => {
269
+ const manifests = await discoverPlugins();
270
+ const states = getPluginStates();
271
+ const ent = getEntitlementStates();
272
+ const plugins = manifests.map(m => ({
273
+ name: m.name, displayName: m.displayName || m.name, version: m.version || '1.0.0',
274
+ core: isCorePlugin(m.name), enabled: isCorePlugin(m.name) || !!states[m.name]?.enabled,
275
+ entitlement: ent[m.name] ?? null
147
276
  }));
277
+ return pluginsBadge(plugins, await latestVersions());
148
278
  });
149
279
 
150
280
  // Scaffold a new plugin from plugins/_template. The plugin is created
@@ -183,7 +313,8 @@ export async function pluginsRoutes(fastify, opts = {}) {
183
313
  const prevState = getPluginStates()[name] || {};
184
314
  const update = {};
185
315
  if (enabled !== undefined) update.enabled = !!enabled;
186
- if (settings !== undefined) update.settings = settings;
316
+ // A secret sent back as the mask keeps its stored value (see maskSettings).
317
+ if (settings !== undefined) update.settings = restoreMasked(settings, prevState.settings || {});
187
318
  savePluginState(name, update);
188
319
 
189
320
  if (!prevState.enabled && !!enabled) {
@@ -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 {