domma-cms 0.75.0 → 0.77.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 +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/action-editor-arrange.js +1 -0
- package/admin/js/lib/api-endpoint-editor-arrange.js +2 -0
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/block-editor-arrange.js +2 -0
- package/admin/js/lib/collection-editor-arrange.js +3 -0
- package/admin/js/lib/collection-entries-arrange.js +1 -0
- package/admin/js/lib/component-editor-arrange.js +27 -0
- package/admin/js/lib/context-menu-editor-arrange.js +1 -0
- package/admin/js/lib/dashboard-arrange.js +1 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/form-editor-arrange.js +1 -0
- package/admin/js/lib/form-submissions-arrange.js +2 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/menu-editor-arrange.js +2 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/page-editor-arrange.js +1 -0
- package/admin/js/lib/plugin-code-arrange.js +1 -0
- package/admin/js/lib/plugins-arrange.js +2 -0
- package/admin/js/lib/project-page-arrange.js +1 -0
- package/admin/js/lib/role-editor-arrange.js +3 -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 +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/user-editor-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/lib/view-editor-arrange.js +14 -0
- package/admin/js/templates/action-editor.html +223 -147
- package/admin/js/templates/api-endpoint-editor.html +172 -117
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/block-editor.html +194 -203
- package/admin/js/templates/collection-editor.html +191 -113
- package/admin/js/templates/collection-entries.html +99 -36
- package/admin/js/templates/component-editor.html +128 -126
- package/admin/js/templates/context-menu-editor.html +208 -148
- package/admin/js/templates/dashboard.html +184 -22
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/form-editor.html +41 -26
- package/admin/js/templates/form-submissions.html +78 -26
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/menu-editor.html +57 -17
- package/admin/js/templates/menu-locations.html +54 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/page-editor.html +47 -26
- package/admin/js/templates/plugin-code.html +91 -73
- package/admin/js/templates/plugins.html +100 -28
- package/admin/js/templates/project-detail.html +83 -145
- package/admin/js/templates/project-settings.html +88 -53
- package/admin/js/templates/role-editor.html +158 -83
- 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/user-editor.html +150 -14
- package/admin/js/templates/users.html +69 -9
- package/admin/js/templates/view-editor.html +203 -180
- package/admin/js/templates/view-preview.html +45 -45
- package/admin/js/views/action-editor.js +7 -1
- package/admin/js/views/api-endpoint-editor.js +2 -3
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/block-editor.js +14 -8
- package/admin/js/views/collection-editor.js +64 -5
- package/admin/js/views/collection-entries.js +50 -1
- package/admin/js/views/component-editor.js +2 -28
- package/admin/js/views/context-menu-editor.js +54 -55
- package/admin/js/views/dashboard.js +7 -1
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +11 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/menu-editor.js +21 -43
- package/admin/js/views/menu-locations.js +16 -1
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/plugin-code.js +23 -4
- package/admin/js/views/plugins.js +97 -39
- package/admin/js/views/project-detail.js +9 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/role-editor.js +11 -3
- 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/user-editor.js +1 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/view-preview.js +1 -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/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/collections.js +13 -1
- package/server/routes/api/dashboard.js +32 -4
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +104 -13
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/notifications.js +140 -14
- 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/users.js +65 -3
- package/server/routes/api/views.js +55 -1
- package/server/server.js +12 -0
- package/server/services/components.js +9 -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 +16 -2
- package/server/services/views.js +14 -0
- package/admin/js/templates/dashboard/activity-feed.html +0 -3
- package/admin/js/templates/dashboard/cache.html +0 -32
- package/admin/js/templates/dashboard/health-detail.html +0 -2
- package/admin/js/templates/dashboard/journeys.html +0 -17
- package/admin/js/templates/dashboard/kpi-strip.html +0 -34
- package/admin/js/templates/dashboard/spike-feed.html +0 -3
- package/admin/js/templates/dashboard/top-pages.html +0 -3
- package/admin/js/templates/dashboard/traffic-chart.html +0 -3
- package/admin/js/templates/plugin-marketplace.html +0 -29
- package/admin/js/views/block-editor-enhance.js +0 -1
- package/admin/js/views/dashboard/lib/escape.js +0 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
- package/admin/js/views/dashboard/widgets/cache.js +0 -1
- package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
- package/admin/js/views/dashboard/widgets/journeys.js +0 -1
- package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
- package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
- package/admin/js/views/dashboard/widgets/traffic-chart.js +0 -1
- package/admin/js/views/plugin-marketplace.js +0 -1
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
@@ -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;
|
|
@@ -73,12 +119,22 @@ export async function usersRoutes(fastify) {
|
|
|
73
119
|
}
|
|
74
120
|
}
|
|
75
121
|
|
|
122
|
+
// Everything the editor sets arrives in this one request: a follow-up PUT that
|
|
123
|
+
// failed would leave the account unrestricted and switched on.
|
|
124
|
+
const {projects, meta, isActive, profile} = request.body || {};
|
|
125
|
+
let user;
|
|
76
126
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
127
|
+
user = await createUser({
|
|
128
|
+
name, email, password, role: targetRole, additionalRoles: requestedAdditional,
|
|
129
|
+
...(Array.isArray(projects) && {projects: projects.filter(p => typeof p === 'string' && p)}),
|
|
130
|
+
...(meta && typeof meta === 'object' && !Array.isArray(meta) && {meta}),
|
|
131
|
+
isActive: isActive !== false
|
|
132
|
+
});
|
|
79
133
|
} catch (err) {
|
|
80
134
|
return reply.status(409).send({ error: err.message });
|
|
81
135
|
}
|
|
136
|
+
if (profile && typeof profile === 'object' && !Array.isArray(profile)) await updateProfile(user.id, profile);
|
|
137
|
+
return reply.status(201).send(user);
|
|
82
138
|
});
|
|
83
139
|
|
|
84
140
|
// Update user
|
|
@@ -119,7 +175,13 @@ export async function usersRoutes(fastify) {
|
|
|
119
175
|
|
|
120
176
|
// Strip profile from core updates and handle separately
|
|
121
177
|
const {profile, ...coreUpdates} = updates;
|
|
122
|
-
|
|
178
|
+
let user;
|
|
179
|
+
try {
|
|
180
|
+
user = await updateUser(id, coreUpdates);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err.code === 'EMAIL_TAKEN') return reply.status(409).send({error: err.message});
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
123
185
|
|
|
124
186
|
if (profile && typeof profile === 'object') {
|
|
125
187
|
await updateProfile(id, profile);
|
|
@@ -9,16 +9,38 @@
|
|
|
9
9
|
* PUT /views/:slug - Update view config
|
|
10
10
|
* DELETE /views/:slug - Delete view config
|
|
11
11
|
* GET /views/:slug/execute - Execute view (admin)
|
|
12
|
+
* POST /views/_preview - Run an UNSAVED config (the editor's Results): {source, connection, stages, limit}
|
|
12
13
|
* GET /views/collection/:slug - List views targeting a collection
|
|
13
14
|
*
|
|
14
15
|
* Public endpoint (role-checked per view access config):
|
|
15
16
|
* GET /views/:slug/public - Execute view publicly
|
|
16
17
|
*/
|
|
17
|
-
import {createView, deleteView, executeView, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
|
|
18
|
+
import {createView, deleteView, executeView, executeViewConfig, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
|
|
18
19
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
19
20
|
import {getRoleLevel} from '../../services/roles.js';
|
|
20
21
|
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Every collection a pipeline's $lookup stages read, as slugs (`cms_` stripped - Mongo-authored views
|
|
25
|
+
* name collections that way). Takes the editor's `{type: '$lookup', config}` stages and raw Mongo
|
|
26
|
+
* `{$lookup: {...}}` alike, and walks nested pipelines: a Mongo `$lookup` can carry its own.
|
|
27
|
+
*/
|
|
28
|
+
export function lookupSources(stages) {
|
|
29
|
+
const out = new Set();
|
|
30
|
+
const walk = (node) => {
|
|
31
|
+
if (Array.isArray(node)) { node.forEach(walk); return; }
|
|
32
|
+
if (!node || typeof node !== 'object') return;
|
|
33
|
+
const add = (cfg) => { if (typeof cfg?.from === 'string' && cfg.from) out.add(cfg.from.replace(/^cms_/, '')); };
|
|
34
|
+
if (node.type === '$lookup') add(node.config);
|
|
35
|
+
for (const [key, value] of Object.entries(node)) {
|
|
36
|
+
if (key === '$lookup') add(value);
|
|
37
|
+
walk(value);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
walk(stages);
|
|
41
|
+
return [...out];
|
|
42
|
+
}
|
|
43
|
+
|
|
22
44
|
/** Every collection by slug - a missing one is a view whose source was deleted. */
|
|
23
45
|
async function collectionsBySlug() {
|
|
24
46
|
const {listCollections} = await import('../../services/collections.js');
|
|
@@ -135,6 +157,38 @@ export async function viewsRoutes(fastify) {
|
|
|
135
157
|
}
|
|
136
158
|
});
|
|
137
159
|
|
|
160
|
+
// -------------------------------------------------------------------------
|
|
161
|
+
// Preview an unsaved config (the view editor's Results tab)
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
//
|
|
164
|
+
// `update`, not `read`: running an arbitrary pipeline reads the source
|
|
165
|
+
// collection, which is what making a view does. The source must be one this
|
|
166
|
+
// user may see (project scope), and it runs as an admin preview - no
|
|
167
|
+
// row-level filter, the same as executing a saved view from the admin.
|
|
168
|
+
// `_` is outside the slug alphabet, so no view can shadow the route.
|
|
169
|
+
fastify.post('/views/_preview', canUpdate, async (request, reply) => {
|
|
170
|
+
const {source, connection = 'default', stages = [], limit} = request.body || {};
|
|
171
|
+
if (typeof source !== 'string' || !source) return reply.status(400).send({error: 'Choose a source collection'});
|
|
172
|
+
if (!Array.isArray(stages)) return reply.status(400).send({error: 'stages must be an array'});
|
|
173
|
+
const {getCollection} = await import('../../services/collections.js');
|
|
174
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
175
|
+
const schema = await getCollection(source).catch(() => null);
|
|
176
|
+
if (!schema) return reply.status(404).send({error: `Collection "${source}" not found`});
|
|
177
|
+
if (!canSeeArtefact(request.user, schema)) return reply.status(403).send({error: 'Forbidden'});
|
|
178
|
+
// A $lookup reads its `from` collection too - the same project check applies.
|
|
179
|
+
for (const from of lookupSources(stages)) {
|
|
180
|
+
const joined = await getCollection(from).catch(() => null);
|
|
181
|
+
if (joined && !canSeeArtefact(request.user, joined)) return reply.status(403).send({error: 'Forbidden'});
|
|
182
|
+
}
|
|
183
|
+
const n = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50);
|
|
184
|
+
try {
|
|
185
|
+
return await executeViewConfig({connection, pipeline: {source, stages}, access: {rowLevel: null}},
|
|
186
|
+
{page: 1, limit: n, user: request.user || null});
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return reply.status(400).send({error: err.message});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
138
192
|
// -------------------------------------------------------------------------
|
|
139
193
|
// Public execute (role-checked per view access config)
|
|
140
194
|
// -------------------------------------------------------------------------
|
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'});
|
|
@@ -544,6 +546,16 @@ registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Actions',
|
|
|
544
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}});
|
|
545
547
|
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages', countUrl: '/api/blocks/_count', inventory: true}});
|
|
546
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}});
|
|
547
559
|
|
|
548
560
|
await registerPlugins(app);
|
|
549
561
|
|