domma-cms 0.92.1 → 0.94.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 +5 -3
- package/admin/css/admin.css +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/action-editor-arrange.js +1 -1
- package/admin/js/lib/api-tokens-arrange.js +2 -2
- package/admin/js/lib/block-editor-arrange.js +1 -1
- package/admin/js/lib/blocks-arrange.js +1 -1
- package/admin/js/lib/collection-entries-arrange.js +1 -1
- package/admin/js/lib/components-arrange.js +1 -1
- package/admin/js/lib/dashboard-arrange.js +1 -1
- package/admin/js/lib/dates.js +1 -0
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/media-arrange.js +1 -1
- package/admin/js/lib/notifications-arrange.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/related.js +1 -1
- package/admin/js/lib/timeline-builder.js +2 -2
- package/admin/js/templates/action-editor.html +6 -5
- package/admin/js/templates/actions-list.html +1 -1
- package/admin/js/templates/contacts.html +1 -1
- package/admin/js/templates/docs/api-actions.html +86 -60
- package/admin/js/templates/docs/api-authentication.html +159 -123
- package/admin/js/templates/docs/api-builder.html +197 -0
- package/admin/js/templates/docs/api-collections.html +199 -259
- package/admin/js/templates/docs/api-external.html +225 -0
- package/admin/js/templates/docs/api-forms.html +268 -0
- package/admin/js/templates/docs/api-layouts.html +70 -45
- package/admin/js/templates/docs/api-media.html +57 -80
- package/admin/js/templates/docs/api-navigation.html +66 -22
- package/admin/js/templates/docs/api-pages.html +109 -129
- package/admin/js/templates/docs/api-plugins.html +123 -61
- package/admin/js/templates/docs/api-scaffold.html +185 -0
- package/admin/js/templates/docs/api-settings.html +72 -64
- package/admin/js/templates/docs/api-users.html +74 -107
- package/admin/js/templates/docs/api-views.html +68 -54
- package/admin/js/templates/docs/components-howto.html +20 -17
- package/admin/js/templates/docs/components-reference.html +13 -16
- package/admin/js/templates/docs/components-rules.html +7 -6
- package/admin/js/templates/docs/components-walkthrough.html +19 -19
- package/admin/js/templates/docs/tutorial-crud.html +71 -40
- package/admin/js/templates/docs/tutorial-forms.html +51 -35
- package/admin/js/templates/docs/tutorial-plugin.html +132 -56
- package/admin/js/templates/docs/usage-actions.html +61 -15
- package/admin/js/templates/docs/usage-collections.html +108 -0
- package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
- package/admin/js/templates/docs/usage-dconfig.html +0 -3
- package/admin/js/templates/docs/usage-editions.html +213 -0
- package/admin/js/templates/docs/usage-media.html +22 -6
- package/admin/js/templates/docs/usage-navigation.html +74 -18
- package/admin/js/templates/docs/usage-pages.html +60 -20
- package/admin/js/templates/docs/usage-plugins.html +89 -17
- package/admin/js/templates/docs/usage-shortcodes.html +123 -70
- package/admin/js/templates/docs/usage-site-settings.html +50 -18
- package/admin/js/templates/docs/usage-tools.html +73 -0
- package/admin/js/templates/docs/usage-users-roles.html +99 -20
- package/admin/js/templates/docs/usage-views.html +36 -19
- package/admin/js/templates/documentation.html +153 -32
- package/admin/js/templates/page-editor.html +0 -5
- package/admin/js/templates/plugin-guide.html +15 -0
- package/admin/js/templates/plugin-guides.html +21 -0
- package/admin/js/templates/pro-docs.html +53 -234
- package/admin/js/templates/tutorials.html +5 -4
- package/admin/js/views/actions-list.js +3 -3
- package/admin/js/views/analytics.js +5 -5
- package/admin/js/views/api-endpoint-editor.js +2 -2
- package/admin/js/views/block-editor.js +4 -4
- package/admin/js/views/blocks.js +4 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collection-entries.js +7 -7
- package/admin/js/views/component-editor.js +2 -2
- package/admin/js/views/contacts.js +22 -20
- package/admin/js/views/context-menu-editor.js +5 -5
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/form-editor.js +4 -4
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/media.js +3 -3
- package/admin/js/views/menu-editor.js +13 -13
- package/admin/js/views/menu-locations.js +2 -2
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/page-editor.js +8 -8
- package/admin/js/views/plugin-guides.js +5 -0
- package/admin/js/views/project-detail.js +2 -2
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/role-editor.js +4 -4
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +17 -17
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +3 -3
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +2 -2
- package/admin/js/views/view-editor.js +1 -1
- package/bin/cli.js +13 -13
- package/bin/lib/node-version.js +29 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +3 -2
- package/plugins/_lib/admin/mail/reader-view.js +7 -6
- package/plugins/_lib/admin/mail/scheduling.js +4 -2
- package/plugins/_lib/admin/mail/templates.js +4 -4
- package/plugins/_lib/admin/ui/dates.js +85 -0
- package/plugins/blog/CLAUDE.md +31 -22
- package/plugins/blog/admin/views/blog.js +3 -2
- package/plugins/blog/admin/views/comments.js +2 -1
- package/plugins/blog/admin/views/post-editor.js +4 -4
- package/plugins/blog/blocks/blog-card-row.html +1 -1
- package/plugins/blog/blocks/blog-card.html +2 -2
- package/plugins/blog/blocks/blog-post-classic.html +2 -2
- package/plugins/blog/blocks/blog-post-essay.html +2 -2
- package/plugins/blog/blocks/blog-post-feature.html +2 -2
- package/plugins/blog/blocks/blog-post-minimal.html +2 -2
- package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
- package/plugins/blog/blocks/blog-post-split.html +2 -2
- package/plugins/blog/docs/guide.md +205 -0
- package/plugins/blog/lib/layouts.js +3 -3
- package/plugins/blog/lib/page.js +2 -1
- package/plugins/blog/plugin.js +3 -3
- package/plugins/blog/plugin.json +4 -4
- package/plugins/blog/tests/layouts.test.js +6 -0
- package/plugins/feedback/CLAUDE.md +22 -3
- package/plugins/feedback/admin/lib/kit.js +6 -7
- package/plugins/feedback/admin/views/feedback.js +79 -10
- package/plugins/feedback/admin/views/send.js +28 -6
- package/plugins/feedback/docs/guide.md +95 -0
- package/plugins/feedback/lib/receiver.js +9 -2
- package/plugins/feedback/lib/sender.js +3 -2
- package/plugins/feedback/plugin.js +54 -6
- package/plugins/feedback/plugin.json +4 -4
- package/plugins/feedback/tests/api.test.js +74 -2
- package/plugins/free-tier.lock.json +49 -44
- package/plugins/mail-reader/CLAUDE.md +33 -18
- package/plugins/mail-reader/docs/guide.md +147 -0
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/security/CLAUDE.md +4 -1
- package/plugins/security/admin/views/security.js +5 -5
- package/plugins/security/docs/guide.md +170 -0
- package/plugins/security/plugin.js +2 -1
- package/plugins/security/plugin.json +2 -1
- package/plugins/shopping-cart/CLAUDE.md +7 -1
- package/plugins/shopping-cart/admin/lib/kit.js +5 -2
- package/plugins/shopping-cart/admin/views/orders.js +4 -4
- package/plugins/shopping-cart/admin/views/overview.js +2 -2
- package/plugins/shopping-cart/docs/guide.md +191 -0
- package/plugins/shopping-cart/lib/render.js +2 -1
- package/plugins/shopping-cart/plugin.json +3 -3
- package/public/js/collection-browser.js +2 -2
- package/public/js/site.js +1 -1
- package/scripts/gen-instance-secret.js +3 -1
- package/scripts/setup.js +3 -1
- package/server/middleware/auth.js +2 -1
- package/server/routes/api/actions.js +47 -27
- package/server/routes/api/blocks.js +2 -1
- package/server/routes/api/collections.js +16 -52
- package/server/routes/api/contacts.js +66 -3
- package/server/routes/api/documentation.js +42 -0
- package/server/routes/api/notifications.js +3 -2
- package/server/routes/api/users.js +10 -6
- package/server/server.js +16 -1
- package/server/services/actions.js +110 -34
- package/server/services/adapterRegistry.js +169 -16
- package/server/services/adapters/FileAdapter.js +25 -0
- package/server/services/adapters/MongoAdapter.js +23 -0
- package/server/services/collections.js +104 -1
- package/server/services/connectionManager.js +12 -0
- package/server/services/dates.js +81 -0
- package/server/services/docs.js +13 -2
- package/server/services/markdown.js +75 -26
- package/server/services/notification-sources.js +6 -5
- package/server/services/passwordReset.js +2 -1
- package/server/services/permissionRegistry.js +3 -2
- package/server/services/pluginGuides.js +255 -0
- package/server/services/pluginInstaller.js +54 -13
- package/server/services/plugins.js +29 -1
- package/server/services/presetCollections.js +31 -5
- package/server/services/renderer.js +2 -2
- package/server/services/sidebarBadges.js +3 -1
- package/server/services/tools.js +4 -2
- package/server/templates/page.html +2 -2
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
importEntries,
|
|
51
51
|
listCollections,
|
|
52
52
|
listEntries,
|
|
53
|
+
migrateStorage,
|
|
53
54
|
updateCollection,
|
|
54
55
|
updateEntry
|
|
55
56
|
} from '../../services/collections.js';
|
|
@@ -538,55 +539,15 @@ export async function collectionsRoutes(fastify) {
|
|
|
538
539
|
// Storage migration
|
|
539
540
|
// -------------------------------------------------------------------------
|
|
540
541
|
|
|
542
|
+
// Entries keep their ids and meta dates; see migrateStorage() in services/collections.js.
|
|
541
543
|
fastify.post('/collections/:slug/migrate-storage', canUpdate, async (request, reply) => {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const schema = await getCollection(slug);
|
|
550
|
-
if (!schema) return reply.status(404).send({error: 'Collection not found'});
|
|
551
|
-
|
|
552
|
-
const sourceAdapter = schema.storage?.adapter || 'file';
|
|
553
|
-
if (sourceAdapter === storage.adapter) {
|
|
554
|
-
return reply.status(400).send({error: 'Source and target adapters are the same'});
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// Step 1: read all existing entries from current adapter BEFORE schema change
|
|
558
|
-
const {entries} = await listEntries(slug, {limit: 1000000, sort: 'createdAt', order: 'asc'});
|
|
559
|
-
|
|
560
|
-
// Step 2: update schema to new adapter (also invalidates the adapter cache)
|
|
561
|
-
await updateCollection(slug, {...schema, storage});
|
|
562
|
-
|
|
563
|
-
// Step 3: insert all entries into the new adapter
|
|
564
|
-
let migrated = 0;
|
|
565
|
-
for (const entry of entries) {
|
|
566
|
-
try {
|
|
567
|
-
await createEntry(slug, entry.data, {
|
|
568
|
-
createdBy: entry.meta?.createdBy || null,
|
|
569
|
-
source: 'migration'
|
|
570
|
-
});
|
|
571
|
-
migrated++;
|
|
572
|
-
} catch (err) {
|
|
573
|
-
fastify.log.warn(`[migrate-storage] Entry ${entry.id} skipped: ${err.message}`);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Step 4: if migrating away from file storage, archive the old data.json
|
|
578
|
-
if (sourceAdapter === 'file') {
|
|
579
|
-
try {
|
|
580
|
-
const {rename} = await import('fs/promises');
|
|
581
|
-
const {join} = await import('path');
|
|
582
|
-
const dataPath = join(process.cwd(), 'content', 'collections', slug, 'data.json');
|
|
583
|
-
await rename(dataPath, dataPath + '.bak');
|
|
584
|
-
} catch {
|
|
585
|
-
// data.json may not exist or rename already done
|
|
586
|
-
}
|
|
544
|
+
try {
|
|
545
|
+
return await migrateStorage(request.params.slug, request.body?.storage);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
const status = err.statusCode || 500;
|
|
548
|
+
if (status >= 500) fastify.log.error(`[migrate-storage] ${request.params.slug}: ${err.message}`);
|
|
549
|
+
return reply.status(status).send({error: err.message, ...(err.collisions && {collisions: err.collisions})});
|
|
587
550
|
}
|
|
588
|
-
|
|
589
|
-
return {migrated, total: entries.length};
|
|
590
551
|
});
|
|
591
552
|
|
|
592
553
|
// -------------------------------------------------------------------------
|
|
@@ -704,7 +665,10 @@ export async function collectionsRoutes(fastify) {
|
|
|
704
665
|
* Body: { attrs: <base64 JSON of the original shortcode attributes> }
|
|
705
666
|
* Auth: JWT required - `createdBy = <user.id>` is injected server-side
|
|
706
667
|
* (the client cannot tamper with which user's data they see).
|
|
707
|
-
* Returns: { html: '<div class="dm-collection-…">…</div>' }
|
|
668
|
+
* Returns: { html: '<div class="dm-collection-…">…</div>' } - a
|
|
669
|
+
* Collection Browser shell (`scope: 'mine'` in its config) when
|
|
670
|
+
* the attrs are interactive, so `transitions` works; otherwise
|
|
671
|
+
* the static fragment.
|
|
708
672
|
*
|
|
709
673
|
* Security: results are always scoped to `createdBy = <user.id>` -
|
|
710
674
|
* a caller can only ever render their own entries, regardless of the
|
|
@@ -787,10 +751,10 @@ export async function collectionsRoutes(fastify) {
|
|
|
787
751
|
const schema = await getCollection(attrs.slug);
|
|
788
752
|
if (!schema) return reply.status(404).send({ error: 'Collection not found' });
|
|
789
753
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
754
|
+
// Interactive attrs come back as a Collection Browser shell scoped to
|
|
755
|
+
// this viewer (transitions included); the rest as a static fragment.
|
|
756
|
+
const { renderScopedCollection } = await import('../../services/markdown.js');
|
|
757
|
+
const html = await renderScopedCollection(attrs, decoded.id);
|
|
794
758
|
|
|
795
759
|
return { html };
|
|
796
760
|
});
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* is a behaviour change, and this was a relocation. A test pins it so the next
|
|
13
13
|
* reader finds it stated rather than inferred.
|
|
14
14
|
*
|
|
15
|
+
* Because a group is shared, renaming or deleting one rewrites every user's
|
|
16
|
+
* contacts. So only the group's creator (`meta.createdBy`, recorded on new
|
|
17
|
+
* groups) or a holder of `contacts.manageGroups` may do either; a group from
|
|
18
|
+
* before creators were recorded belongs to the permission holders alone.
|
|
19
|
+
*
|
|
15
20
|
* Hand-filtered, as the plugin did it, rather than via rowAccess.js - the right
|
|
16
21
|
* end state, and again not something to smuggle into a move.
|
|
17
22
|
*
|
|
@@ -27,6 +32,30 @@
|
|
|
27
32
|
import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
|
|
28
33
|
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
|
|
29
34
|
import {hooks} from '../../services/hooks.js';
|
|
35
|
+
import {getPermissionsFor} from '../../services/roles.js';
|
|
36
|
+
import {getEffectiveRoles} from '../../services/userRoles.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The permission that lets someone rename or delete a group they did not
|
|
40
|
+
* create. Groups are shared, so a rename or delete changes every user's
|
|
41
|
+
* contacts; without it only the group's creator may do either. Level 0 holds
|
|
42
|
+
* it like everything else; admin is granted it once at boot (server.js).
|
|
43
|
+
*/
|
|
44
|
+
export const MANAGE_GROUPS = 'manageGroups';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Does the caller hold `contacts.manageGroups`? The same union-of-roles test
|
|
48
|
+
* requirePermission() makes, done in the handler because the answer only
|
|
49
|
+
* matters when the caller is not the group's creator.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} request
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
function defaultHoldsManageGroups(request) {
|
|
55
|
+
if (!request.user) return false;
|
|
56
|
+
const allowed = getPermissionsFor('contacts', MANAGE_GROUPS);
|
|
57
|
+
return getEffectiveRoles(request.user).some(r => allowed.includes(r));
|
|
58
|
+
}
|
|
30
59
|
|
|
31
60
|
const CONTACTS_SLUG = 'contacts-contacts';
|
|
32
61
|
const GROUPS_SLUG = 'contacts-groups';
|
|
@@ -217,7 +246,7 @@ function toGroupName(entry) {
|
|
|
217
246
|
export async function contactsRoutes(fastify, opts = {}) {
|
|
218
247
|
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
219
248
|
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
220
|
-
|
|
249
|
+
const holdsManageGroups = opts.holdsManageGroups || defaultHoldsManageGroups;
|
|
221
250
|
|
|
222
251
|
function userId(request) {
|
|
223
252
|
return request.user?.id ?? request.user?.sub ?? null;
|
|
@@ -232,9 +261,22 @@ export async function contactsRoutes(fastify, opts = {}) {
|
|
|
232
261
|
|
|
233
262
|
async function loadGroups() {
|
|
234
263
|
const {entries} = await listEntries(GROUPS_SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
|
|
235
|
-
return entries.map(e => ({id: e.id, name: toGroupName(e)}));
|
|
264
|
+
return entries.map(e => ({id: e.id, name: toGroupName(e), createdBy: e.meta?.createdBy ?? null}));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* May this caller rename or delete this group? Its creator may; so may
|
|
269
|
+
* anyone holding contacts.manageGroups. A group from before creators were
|
|
270
|
+
* recorded has none, so only permission holders can touch it.
|
|
271
|
+
*/
|
|
272
|
+
function canManageGroup(request, group) {
|
|
273
|
+
const uid = userId(request);
|
|
274
|
+
if (uid && group.createdBy && group.createdBy === uid) return true;
|
|
275
|
+
return holdsManageGroups(request) === true;
|
|
236
276
|
}
|
|
237
277
|
|
|
278
|
+
const NOT_YOURS = 'Only the person who created this group, or someone allowed to manage every group, can rename or delete it.';
|
|
279
|
+
|
|
238
280
|
/**
|
|
239
281
|
* Build the stored shape from whatever a caller sent.
|
|
240
282
|
*
|
|
@@ -396,6 +438,24 @@ export async function contactsRoutes(fastify, opts = {}) {
|
|
|
396
438
|
return reply.send(groups.map(g => g.name));
|
|
397
439
|
});
|
|
398
440
|
|
|
441
|
+
/**
|
|
442
|
+
* GET /api/contacts/groups/access - who may do what with each group, for
|
|
443
|
+
* the screens. `/groups` stays a list of names: other Tools read it.
|
|
444
|
+
* `{manageAll, groups: [{name, mine, canManage}]}`
|
|
445
|
+
*/
|
|
446
|
+
fastify.get('/groups/access', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
|
|
447
|
+
const uid = userId(request);
|
|
448
|
+
const manageAll = holdsManageGroups(request) === true;
|
|
449
|
+
const groups = await loadGroups();
|
|
450
|
+
return reply.send({
|
|
451
|
+
manageAll,
|
|
452
|
+
groups: groups.map(g => {
|
|
453
|
+
const mine = Boolean(uid && g.createdBy && g.createdBy === uid);
|
|
454
|
+
return {name: g.name, mine, canManage: mine || manageAll};
|
|
455
|
+
})
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
399
459
|
/** POST /api/contacts/groups */
|
|
400
460
|
fastify.post('/groups', {preHandler: [authenticate, requirePermission('contacts', 'create')]}, async (request, reply) => {
|
|
401
461
|
const { name } = request.body ?? {};
|
|
@@ -410,7 +470,8 @@ export async function contactsRoutes(fastify, opts = {}) {
|
|
|
410
470
|
return reply.code(400).send({ error: 'Group already exists' });
|
|
411
471
|
}
|
|
412
472
|
|
|
413
|
-
|
|
473
|
+
// The creator is recorded: it is who may rename or delete the group.
|
|
474
|
+
await createEntry(GROUPS_SLUG, {name: trimmed}, {createdBy: userId(request), source: 'admin'});
|
|
414
475
|
return reply.code(201).send({ name: trimmed });
|
|
415
476
|
});
|
|
416
477
|
|
|
@@ -426,6 +487,7 @@ export async function contactsRoutes(fastify, opts = {}) {
|
|
|
426
487
|
const groups = await loadGroups();
|
|
427
488
|
const grpEntry = groups.find(g => g.name === oldName);
|
|
428
489
|
if (!grpEntry) return reply.code(404).send({error: 'Group not found'});
|
|
490
|
+
if (!canManageGroup(request, grpEntry)) return reply.code(403).send({error: NOT_YOURS});
|
|
429
491
|
|
|
430
492
|
const trimmed = newName.trim();
|
|
431
493
|
if (groups.some(g => g.name === trimmed)) {
|
|
@@ -457,6 +519,7 @@ export async function contactsRoutes(fastify, opts = {}) {
|
|
|
457
519
|
const groups = await loadGroups();
|
|
458
520
|
const grpEntry = groups.find(g => g.name === name);
|
|
459
521
|
if (!grpEntry) return reply.code(404).send({error: 'Group not found'});
|
|
522
|
+
if (!canManageGroup(request, grpEntry)) return reply.code(403).send({error: NOT_YOURS});
|
|
460
523
|
|
|
461
524
|
// Remove the group entry
|
|
462
525
|
await deleteEntry(GROUPS_SLUG, grpEntry.id);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Documentation API - plugin guides for the admin's Documentation folder.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/documentation/plugins - the guides this user may read
|
|
5
|
+
* GET /api/documentation/plugins/:name - one guide, its first page rendered
|
|
6
|
+
* GET /api/documentation/plugins/:name/:page - one guide, the named page rendered
|
|
7
|
+
*
|
|
8
|
+
* Signed-in users only. A guide is readable by whoever can use the plugin
|
|
9
|
+
* (services/pluginGuides.js decides), so a role that cannot open Invoices is
|
|
10
|
+
* not shown the Invoices guide either.
|
|
11
|
+
*/
|
|
12
|
+
import {authenticate as defaultAuthenticate} from '../../middleware/auth.js';
|
|
13
|
+
import {getPermissionsForRoles} from '../../services/roles.js';
|
|
14
|
+
import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
|
|
15
|
+
import {getEffectiveRoles} from '../../services/userRoles.js';
|
|
16
|
+
import {getGuide, listGuides} from '../../services/pluginGuides.js';
|
|
17
|
+
|
|
18
|
+
/** The caller's permissions - the same union /api/auth/permissions answers. */
|
|
19
|
+
function permissionsOf(user, resolve) {
|
|
20
|
+
if (resolve) return resolve(user);
|
|
21
|
+
const roles = getEffectiveRoles(user || {});
|
|
22
|
+
return getPermissionsForRoles(roles, getEffectiveRegistry().map(r => r.key));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function documentationRoutes(fastify, opts = {}) {
|
|
26
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
27
|
+
const signedIn = {preHandler: [authenticate]};
|
|
28
|
+
|
|
29
|
+
fastify.get('/documentation/plugins', signedIn, async (request) => {
|
|
30
|
+
return {guides: listGuides(permissionsOf(request.user, opts.permissionsOf))};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const one = async (request, reply) => {
|
|
34
|
+
const {name, page} = request.params;
|
|
35
|
+
const out = await getGuide(name, page, permissionsOf(request.user, opts.permissionsOf));
|
|
36
|
+
if (out.status === 403) return reply.code(403).send({statusCode: 403, error: 'Forbidden', message: 'You cannot use this plugin.'});
|
|
37
|
+
if (out.status !== 200) return reply.code(404).send({statusCode: 404, error: 'Not Found', message: 'No such guide.'});
|
|
38
|
+
return out.guide;
|
|
39
|
+
};
|
|
40
|
+
fastify.get('/documentation/plugins/:name', signedIn, one);
|
|
41
|
+
fastify.get('/documentation/plugins/:name/:page', signedIn, one);
|
|
42
|
+
}
|
|
@@ -27,6 +27,7 @@ import { randomUUID } from 'crypto';
|
|
|
27
27
|
import { getAdapter } from '../../services/adapterRegistry.js';
|
|
28
28
|
import {AUDIENCES, isActiveFor, listNotificationSources, runSource, saveSourceSetting} from '../../services/notify.js';
|
|
29
29
|
import {getEffectiveRoles} from '../../services/userRoles.js';
|
|
30
|
+
import {fmtDate} from '../../services/dates.js';
|
|
30
31
|
|
|
31
32
|
const SLUG = 'notifications';
|
|
32
33
|
|
|
@@ -100,7 +101,7 @@ export function notificationsBadge(active = []) {
|
|
|
100
101
|
],
|
|
101
102
|
items: unread.slice(0, 6).map(e => ({
|
|
102
103
|
text: String(e.data.title || '(no title)').slice(0, 80),
|
|
103
|
-
meta: [sev(e),
|
|
104
|
+
meta: [sev(e), fmtDate(e.data.createdAt)].filter(Boolean).join(' · '),
|
|
104
105
|
...(sev(e) === 'critical' && {tone: 'danger'})
|
|
105
106
|
})),
|
|
106
107
|
empty: 'Nothing unread.'
|
|
@@ -121,7 +122,7 @@ export function notificationsBadge(active = []) {
|
|
|
121
122
|
*/
|
|
122
123
|
export function sampleNotifications(now = new Date(), origin = '', forUser = null) {
|
|
123
124
|
const expiresAt = new Date(now.getTime() + 7 * 86_400_000).toISOString();
|
|
124
|
-
const note = `\n\n(A sample - it goes by itself on ${expiresAt
|
|
125
|
+
const note = `\n\n(A sample - it goes by itself on ${fmtDate(expiresAt, {utc: true})}.)`;
|
|
125
126
|
const samples = [
|
|
126
127
|
{severity: 'critical', title: 'Plugin failed to load: Calendar',
|
|
127
128
|
body: 'The Calendar plugin stopped with an error while the site started, so its screens and pages are missing. Check Plugins for the error, then restart the site.',
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Users API
|
|
3
|
-
*
|
|
3
|
+
* Access is by the `users` permission (read/create/update/delete - which roles
|
|
4
|
+
* hold it is role data), plus canManageUser(): you can only create, change or
|
|
5
|
+
* delete an account whose role is junior to yours.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/users - list all users (users.read)
|
|
4
8
|
* GET /api/users/_count - the sidebar badge (ids are uuids - nothing can shadow it)
|
|
5
|
-
* GET /api/users/:id - single user (
|
|
6
|
-
* POST /api/users - create user (
|
|
7
|
-
* PUT /api/users/:id - update user (
|
|
8
|
-
* DELETE /api/users/:id - delete user (
|
|
9
|
+
* GET /api/users/:id - single user (users.read, or self)
|
|
10
|
+
* POST /api/users - create user (users.create; only a role junior to yours)
|
|
11
|
+
* PUT /api/users/:id - update user (users.update; only a junior account)
|
|
12
|
+
* DELETE /api/users/:id - delete user (users.delete; only a junior account, no self-delete)
|
|
9
13
|
* POST /api/users/:id/password-reset - {via: 'email'|'link'} - email them a reset link, or return one to copy
|
|
10
14
|
* DELETE /api/users/:id/password-reset - withdraw an unused reset link
|
|
11
15
|
* Both: users.update and a more senior role than theirs (canManageUser); never your own account.
|
|
@@ -83,7 +87,7 @@ export async function usersRoutes(fastify) {
|
|
|
83
87
|
return usersBadge((await listUsers()).filter(u => canSeeArtefact(request.user, u)));
|
|
84
88
|
});
|
|
85
89
|
|
|
86
|
-
// Get single user (
|
|
90
|
+
// Get single user (users.read, or the user themselves)
|
|
87
91
|
fastify.get('/users/:id', { preHandler: [authenticate] }, async (request, reply) => {
|
|
88
92
|
const { id } = request.params;
|
|
89
93
|
const actor = request.user;
|
package/server/server.js
CHANGED
|
@@ -21,7 +21,7 @@ import {createRequire} from 'module';
|
|
|
21
21
|
import {config, getConfig} from './config.js';
|
|
22
22
|
import {getLoadedPlugins, getPluginSettings, recordPluginLoadFailure, registerPlugins} from './services/plugins.js';
|
|
23
23
|
import {notifierFor} from './services/notify.js';
|
|
24
|
-
import {load as loadRoles, seed as seedRoles} from './services/roles.js';
|
|
24
|
+
import {grantPermissionOnce, load as loadRoles, seed as seedRoles} from './services/roles.js';
|
|
25
25
|
import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
|
|
26
26
|
import {seedAll as seedPresetCollections} from './services/presetCollections.js';
|
|
27
27
|
import {seedCoreProject, seedDocsProject} from './services/projects.js';
|
|
@@ -231,6 +231,9 @@ try {
|
|
|
231
231
|
|
|
232
232
|
await seedRoles();
|
|
233
233
|
await loadRoles();
|
|
234
|
+
// Renaming or deleting a shared contact group is for its creator or a holder of
|
|
235
|
+
// contacts.manageGroups; admins get it once - removed by hand, it stays removed.
|
|
236
|
+
await grantPermissionOnce('admin', 'contacts.manageGroups', 'core').catch(() => {});
|
|
234
237
|
await seedUserProfiles();
|
|
235
238
|
await ensureAllProfiles();
|
|
236
239
|
await seedPresetCollections();
|
|
@@ -484,6 +487,7 @@ const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
|
|
|
484
487
|
const {apiEndpointsRoutes} = await import('./routes/api/api-endpoints.js');
|
|
485
488
|
const {endpointsPublicRoutes} = await import('./routes/api/endpoints-public.js');
|
|
486
489
|
const {sidebarRoutes} = await import('./routes/api/sidebar.js');
|
|
490
|
+
const {documentationRoutes} = await import('./routes/api/documentation.js');
|
|
487
491
|
const { mediaRoutes } = await import('./routes/api/media.js');
|
|
488
492
|
const { usersRoutes } = await import('./routes/api/users.js');
|
|
489
493
|
const { pluginsRoutes } = await import('./routes/api/plugins.js');
|
|
@@ -522,6 +526,7 @@ await app.register(apiTokensRoutes, {prefix: '/api'});
|
|
|
522
526
|
await app.register(apiEndpointsRoutes, {prefix: '/api'});
|
|
523
527
|
await app.register(endpointsPublicRoutes, {prefix: '/api'});
|
|
524
528
|
await app.register(sidebarRoutes, {prefix: '/api'});
|
|
529
|
+
await app.register(documentationRoutes, {prefix: '/api'});
|
|
525
530
|
await app.register(mediaRoutes, { prefix: '/api' });
|
|
526
531
|
await app.register(usersRoutes, { prefix: '/api' });
|
|
527
532
|
await app.register(pluginsRoutes, { prefix: '/api' });
|
|
@@ -630,6 +635,16 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts
|
|
|
630
635
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
|
|
631
636
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
|
|
632
637
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
|
|
638
|
+
// Documentation pages added after the sidebar menu was seeded (0.93.1). The menu is
|
|
639
|
+
// seeded once, so new pages reach existing sites only by registering here. The two
|
|
640
|
+
// sub-folders have no key in older menus; findFolder() falls back to their name.
|
|
641
|
+
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Collections & Forms', url: '#/docs/usage/collections', icon: 'database'}});
|
|
642
|
+
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Built-in Tools', url: '#/docs/usage/tools', icon: 'tool'}});
|
|
643
|
+
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions & Licences', url: '#/docs/usage/editions', icon: 'key'}});
|
|
644
|
+
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Forms', url: '#/docs/api/forms', icon: 'layout'}});
|
|
645
|
+
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Scaffold', url: '#/docs/api/scaffold', icon: 'package'}});
|
|
646
|
+
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'External API & Tokens', url: '#/docs/api/external', icon: 'key'}});
|
|
647
|
+
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'API Builder', url: '#/docs/api/builder', icon: 'code'}});
|
|
633
648
|
|
|
634
649
|
// Core's notification sources, before plugins add theirs (0.80).
|
|
635
650
|
const {registerCoreSources} = await import('./services/notification-sources.js');
|
|
@@ -29,10 +29,27 @@
|
|
|
29
29
|
import {v4 as uuidv4} from 'uuid';
|
|
30
30
|
import {createEntry, deleteEntry, getEntry, updateEntry} from './collections.js';
|
|
31
31
|
import {checkEntryAccess} from './rowAccess.js';
|
|
32
|
+
import {canReadView} from './views.js';
|
|
33
|
+
import {getEffectiveLevel} from './userRoles.js';
|
|
32
34
|
|
|
33
35
|
/** MongoDB collection where action configs are stored. */
|
|
34
36
|
const ACTIONS_COLLECTION = 'cms__actions';
|
|
35
37
|
|
|
38
|
+
/** A Db handed in by tests (useActionsDb); null means the 'default' connection. */
|
|
39
|
+
let testDb = null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tests only: store action configs in `db` (anything with Mongo's
|
|
43
|
+
* collection().find/findOne/insertOne/replaceOne/deleteOne) instead of the
|
|
44
|
+
* 'default' connection, so the routes can be driven without MongoDB. Pass
|
|
45
|
+
* null to go back.
|
|
46
|
+
*
|
|
47
|
+
* @param {object|null} db
|
|
48
|
+
*/
|
|
49
|
+
export function useActionsDb(db) {
|
|
50
|
+
testDb = db || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
// ---------------------------------------------------------------------------
|
|
37
54
|
// Internal helpers
|
|
38
55
|
// ---------------------------------------------------------------------------
|
|
@@ -44,6 +61,7 @@ const ACTIONS_COLLECTION = 'cms__actions';
|
|
|
44
61
|
* @throws {Error} If MongoDB is not configured
|
|
45
62
|
*/
|
|
46
63
|
async function getMetaDb() {
|
|
64
|
+
if (testDb) return testDb;
|
|
47
65
|
try {
|
|
48
66
|
const { getDb } = await import('./connectionManager.js');
|
|
49
67
|
return getDb('default');
|
|
@@ -415,57 +433,110 @@ export async function listActionsForCollection(collectionSlug) {
|
|
|
415
433
|
.toArray();
|
|
416
434
|
}
|
|
417
435
|
|
|
436
|
+
/**
|
|
437
|
+
* May this user run the action (public execute) or be offered it as a
|
|
438
|
+
* transition? Any role in `access.roles` is enough, each on its usual ladder
|
|
439
|
+
* (the role and every more senior one; `=role` exact), across every role the
|
|
440
|
+
* user holds - the rule views use, so this IS canReadView(), not a third
|
|
441
|
+
* implementation.
|
|
442
|
+
*
|
|
443
|
+
* - A role name this site does not have admits nobody but the level-0 role
|
|
444
|
+
* (checkVisibility's rule for an unknown name), so a deleted or mistyped
|
|
445
|
+
* role never opens the action to everyone. `public` is not a role here:
|
|
446
|
+
* running an action always needs a signed-in user, so it counts as unknown.
|
|
447
|
+
* - No roles named (missing or empty list) is the admin tier, levels 0 and 1 -
|
|
448
|
+
* what the old `['admin', 'super-admin']` default names, and what the
|
|
449
|
+
* Actions list shows ("admins").
|
|
450
|
+
*
|
|
451
|
+
* @param {object|null} user - {role, additionalRoles}
|
|
452
|
+
* @param {object|null} action - action config
|
|
453
|
+
* @returns {boolean}
|
|
454
|
+
*/
|
|
455
|
+
export function canRunAction(user, action) {
|
|
456
|
+
if (!user?.role) return false;
|
|
457
|
+
const roles = Array.isArray(action?.access?.roles)
|
|
458
|
+
? action.access.roles.filter(r => typeof r === 'string' && r)
|
|
459
|
+
: [];
|
|
460
|
+
if (!roles.length) return canReadView(user, {access: {roles: []}});
|
|
461
|
+
const named = roles.filter(r => r !== 'public');
|
|
462
|
+
if (!named.length) return getEffectiveLevel(user) === 0;
|
|
463
|
+
return canReadView(user, {access: {roles: named}});
|
|
464
|
+
}
|
|
465
|
+
|
|
418
466
|
/**
|
|
419
467
|
* List the actions that represent valid next-state transitions for a given
|
|
420
468
|
* entry as seen by a given user. Used by the per-row transitions UI in the
|
|
421
|
-
* Collection Browser
|
|
469
|
+
* Collection Browser (an interactive `[collection … transitions]` on a page).
|
|
422
470
|
*
|
|
423
471
|
* Criteria for inclusion:
|
|
424
472
|
* 1. action.collection matches the entry's collection
|
|
425
473
|
* 2. action.transition is set and action.transition.from includes the
|
|
426
474
|
* entry's current value of action.transition.field
|
|
427
|
-
* 3. action.access.roles
|
|
428
|
-
*
|
|
475
|
+
* 3. canRunAction(): any of action.access.roles admits the user, on its
|
|
476
|
+
* ladder (more senior roles inherit it)
|
|
429
477
|
*
|
|
430
478
|
* Returns a stripped projection - slug, title, trigger config, transition -
|
|
431
479
|
* so the client can render buttons without leaking step internals.
|
|
432
480
|
*
|
|
433
481
|
* @param {string} collectionSlug
|
|
434
482
|
* @param {object} entry - The entry being inspected (needed for field-value match)
|
|
435
|
-
* @param {object} [user] - { role } - anonymous if missing
|
|
483
|
+
* @param {object} [user] - { id, role, additionalRoles } - anonymous if missing
|
|
484
|
+
* @param {object} [opts]
|
|
485
|
+
* @param {string} [opts.scope] - 'mine': the row is offered nothing unless it is
|
|
486
|
+
* in the viewer's scope (entryInScope)
|
|
436
487
|
* @returns {Promise<object[]>}
|
|
437
488
|
*/
|
|
438
|
-
export async function listTransitionsForEntry(collectionSlug, entry, user = null) {
|
|
489
|
+
export async function listTransitionsForEntry(collectionSlug, entry, user = null, {scope} = {}) {
|
|
490
|
+
if (!entryInScope(entry, user, scope)) return [];
|
|
439
491
|
const all = await listActionsForCollection(collectionSlug);
|
|
440
492
|
if (!Array.isArray(all) || !all.length) return [];
|
|
441
493
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
.
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
494
|
+
// canRunAction walks every role the user holds, so a user who is primarily
|
|
495
|
+
// a candidate but also an admin sees BOTH the candidate-only "withdraw" AND
|
|
496
|
+
// the admin-only "approve" transitions on the same entry.
|
|
497
|
+
const offered = all.filter(a => {
|
|
498
|
+
if (!a.transition) return false;
|
|
499
|
+
const allowedFrom = Array.isArray(a.transition.from) ? a.transition.from : [a.transition.from].filter(Boolean);
|
|
500
|
+
if (allowedFrom.length) {
|
|
501
|
+
const current = entry.data?.[a.transition.field];
|
|
502
|
+
if (!allowedFrom.includes(current)) return false;
|
|
503
|
+
}
|
|
504
|
+
return canRunAction(user, a);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Row-level rules too, so a button is only offered when executeAction()
|
|
508
|
+
// would let it through (a withdraw limited to the owner is not shown on
|
|
509
|
+
// someone else's row).
|
|
510
|
+
const kept = [];
|
|
511
|
+
for (const a of offered) {
|
|
512
|
+
if (await checkEntryAccess(entry, user, a.access?.rowLevel)) kept.push(a);
|
|
513
|
+
}
|
|
514
|
+
return kept.map(a => ({
|
|
515
|
+
slug: a.slug,
|
|
516
|
+
title: a.title,
|
|
517
|
+
trigger: a.trigger,
|
|
518
|
+
transition: a.transition
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Is this entry inside the display scope the request came from? The only scope
|
|
524
|
+
* is `mine` (`[collection scope="mine"]`): the entry's `meta.createdBy` must be
|
|
525
|
+
* the user's id - the filter render-scope and `GET /:slug/public?scope=mine`
|
|
526
|
+
* list with, so a viewer acts only on the rows that display showed them. No
|
|
527
|
+
* level bypass: scope="mine" shows an admin their own rows too. No scope leaves
|
|
528
|
+
* the decision to the action's own access; a scope this code does not know
|
|
529
|
+
* fails closed.
|
|
530
|
+
*
|
|
531
|
+
* @param {object|null} entry
|
|
532
|
+
* @param {object|null} user
|
|
533
|
+
* @param {string} [scope]
|
|
534
|
+
* @returns {boolean}
|
|
535
|
+
*/
|
|
536
|
+
export function entryInScope(entry, user, scope) {
|
|
537
|
+
if (!scope) return true;
|
|
538
|
+
if (scope !== 'mine') return false;
|
|
539
|
+
return !!user?.id && entry?.meta?.createdBy === user.id;
|
|
469
540
|
}
|
|
470
541
|
|
|
471
542
|
// ---------------------------------------------------------------------------
|
|
@@ -481,17 +552,22 @@ export async function listTransitionsForEntry(collectionSlug, entry, user = null
|
|
|
481
552
|
* @param {string} entryId - ID of the target entry
|
|
482
553
|
* @param {object} [opts]
|
|
483
554
|
* @param {object} [opts.user] - Executing user object (for template context)
|
|
555
|
+
* @param {string} [opts.scope] - 'mine' when run from a scope="mine" display:
|
|
556
|
+
* a row outside it is refused (403, entryInScope)
|
|
484
557
|
* @returns {Promise<{ success: boolean, stepsCompleted: number, results: object[] }>}
|
|
485
558
|
* @throws {Error} If action or entry not found
|
|
486
559
|
*/
|
|
487
|
-
export async function executeAction(slug, entryId, { user = null } = {}) {
|
|
560
|
+
export async function executeAction(slug, entryId, { user = null, scope } = {}) {
|
|
488
561
|
const action = await getAction(slug);
|
|
489
562
|
if (!action) throw new Error(`Action "${slug}" not found`);
|
|
490
563
|
|
|
491
564
|
const entry = await getEntry(action.collection, entryId);
|
|
492
565
|
if (!entry) throw new Error(`Entry "${entryId}" not found in collection "${action.collection}"`);
|
|
493
566
|
|
|
494
|
-
|
|
567
|
+
// Out of the requesting display's scope (scope="mine": not the viewer's
|
|
568
|
+
// row) is refused exactly like a row-level denial, and before the state
|
|
569
|
+
// check, so an outside row never learns its state through a 409.
|
|
570
|
+
if (!entryInScope(entry, user, scope) || !(await checkEntryAccess(entry, user, action.access?.rowLevel))) {
|
|
495
571
|
const err = new Error('Row-level access denied');
|
|
496
572
|
err.statusCode = 403;
|
|
497
573
|
throw err;
|