domma-cms 0.17.0 → 0.21.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 +39 -3
- package/admin/css/admin.css +1 -1
- package/admin/css/dashboard.css +1 -1
- package/admin/index.html +2 -2
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +6 -6
- package/admin/js/lib/project-context.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -0
- package/admin/js/templates/action-editor.html +7 -0
- package/admin/js/templates/block-editor.html +7 -0
- package/admin/js/templates/collection-editor.html +9 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard.html +4 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +98 -0
- package/admin/js/templates/menu-locations.html +14 -0
- package/admin/js/templates/menus.html +14 -0
- package/admin/js/templates/page-editor.html +9 -2
- package/admin/js/templates/project-detail.html +50 -0
- package/admin/js/templates/project-editor.html +45 -0
- package/admin/js/templates/project-settings.html +60 -0
- package/admin/js/templates/projects.html +13 -0
- package/admin/js/templates/role-editor.html +11 -0
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/templates/tutorials.html +335 -2
- package/admin/js/templates/view-editor.html +7 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +19 -0
- package/admin/js/views/menu-locations.js +1 -0
- package/admin/js/views/menus.js +5 -0
- package/admin/js/views/page-editor.js +41 -36
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/project-detail.js +4 -0
- package/admin/js/views/project-editor.js +1 -0
- package/admin/js/views/project-settings.js +1 -0
- package/admin/js/views/projects.js +7 -0
- package/admin/js/views/role-editor.js +1 -1
- package/admin/js/views/roles.js +3 -3
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/tutorials.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/menu-locations.json +5 -0
- package/config/menus/admin-sidebar.json +185 -0
- package/config/menus/footer.json +33 -0
- package/config/menus/main.json +35 -0
- package/config/menus/sproj-1779696558011-menu.json +17 -0
- package/config/menus/sproj-1779696960337-menu.json +18 -0
- package/config/menus/sproj-1779696985353-menu.json +18 -0
- package/config/site.json +6 -22
- package/package.json +4 -3
- package/plugins/analytics/daily.json +3 -0
- package/plugins/analytics/journeys.json +8 -0
- package/plugins/analytics/lifetime.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +4 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/middleware/auth.js +88 -22
- package/server/routes/api/actions.js +58 -5
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/blocks.js +18 -3
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/collections.js +201 -8
- package/server/routes/api/forms.js +266 -21
- package/server/routes/api/menu-locations.js +46 -0
- package/server/routes/api/menus.js +115 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/pages.js +1 -1
- package/server/routes/api/projects.js +107 -0
- package/server/routes/api/scaffold.js +86 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/api/sidebar.js +23 -0
- package/server/routes/api/users.js +32 -7
- package/server/routes/api/views.js +10 -2
- package/server/routes/public.js +88 -7
- package/server/server.js +54 -3
- package/server/services/actions.js +137 -8
- package/server/services/adapters/FileAdapter.js +23 -8
- package/server/services/adapters/MongoAdapter.js +36 -18
- package/server/services/blocks.js +23 -8
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +102 -12
- package/server/services/content.js +25 -6
- package/server/services/filterEngine.js +281 -0
- package/server/services/forms.js +3 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +711 -124
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +422 -0
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/plugins.js +9 -2
- package/server/services/presetCollections.js +22 -0
- package/server/services/projects.js +429 -0
- package/server/services/recipes/contact-list.json +78 -0
- package/server/services/recipes/onboarding.json +426 -0
- package/server/services/references.js +174 -0
- package/server/services/renderer.js +237 -40
- package/server/services/roles.js +6 -1
- package/server/services/rowAccess.js +86 -13
- package/server/services/scaffolder.js +465 -0
- package/server/services/sidebar-migration.js +117 -0
- package/server/services/sitemap.js +112 -0
- package/server/services/userRoles.js +86 -0
- package/server/services/users.js +23 -2
- package/server/services/views.js +19 -4
- package/server/templates/page.html +135 -130
- /package/config/{navigation.json → navigation.json.bak} +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu locations API
|
|
3
|
+
*
|
|
4
|
+
* GET /api/menu-locations — current slot → menu-slug map
|
|
5
|
+
* PUT /api/menu-locations — replace the map (validates against registry + menus)
|
|
6
|
+
* GET /api/menu-locations/registry — list registered slots (core + plugin-contributed)
|
|
7
|
+
*
|
|
8
|
+
* Auth middlewares are accepted as options so tests can supply no-ops without
|
|
9
|
+
* needing to register `@fastify/jwt` + seed the roles cache. In production the
|
|
10
|
+
* defaults are the real `authenticate` / `requirePermission` from
|
|
11
|
+
* middleware/auth.js.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
authenticate as defaultAuthenticate,
|
|
15
|
+
requirePermission as defaultRequirePermission
|
|
16
|
+
} from '../../middleware/auth.js';
|
|
17
|
+
import {getLocationRegistry, getLocations, setLocations} from '../../services/menus.js';
|
|
18
|
+
import * as cache from '../../services/cache/index.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register the menu-locations routes.
|
|
22
|
+
*
|
|
23
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
24
|
+
* @param {{authenticate?: Function, requirePermission?: Function}} [opts]
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
export async function menuLocationsRoutes(fastify, opts = {}) {
|
|
28
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
29
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
30
|
+
|
|
31
|
+
const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
|
|
32
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('menus', 'update')]};
|
|
33
|
+
|
|
34
|
+
fastify.get('/menu-locations', canRead, async () => getLocations());
|
|
35
|
+
fastify.get('/menu-locations/registry', canRead, async () => getLocationRegistry());
|
|
36
|
+
|
|
37
|
+
fastify.put('/menu-locations', canUpdate, async (request, reply) => {
|
|
38
|
+
try {
|
|
39
|
+
await setLocations(request.body || {});
|
|
40
|
+
await cache.invalidateTags(['menu-locations', 'nav']);
|
|
41
|
+
return {success: true};
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return reply.status(400).send({error: err.message});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menus API
|
|
3
|
+
*
|
|
4
|
+
* GET /api/menus — list (metadata only)
|
|
5
|
+
* GET /api/menus/:slug — full menu
|
|
6
|
+
* POST /api/menus — create
|
|
7
|
+
* PUT /api/menus/:slug — replace
|
|
8
|
+
* DELETE /api/menus/:slug — delete (409 if mapped to a slot)
|
|
9
|
+
* POST /api/menus/:slug/duplicate — copy as <slug>-copy
|
|
10
|
+
*
|
|
11
|
+
* Auth middlewares are accepted as options so tests can supply no-ops without
|
|
12
|
+
* needing to register `@fastify/jwt` + seed the roles cache. In production the
|
|
13
|
+
* defaults are the real `authenticate` / `requirePermission` from
|
|
14
|
+
* middleware/auth.js.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
authenticate as defaultAuthenticate,
|
|
18
|
+
requirePermission as defaultRequirePermission
|
|
19
|
+
} from '../../middleware/auth.js';
|
|
20
|
+
import {
|
|
21
|
+
createMenu,
|
|
22
|
+
deleteMenu,
|
|
23
|
+
duplicateMenu,
|
|
24
|
+
getLocations,
|
|
25
|
+
getMenu,
|
|
26
|
+
listMenus,
|
|
27
|
+
updateMenu
|
|
28
|
+
} from '../../services/menus.js';
|
|
29
|
+
import * as cache from '../../services/cache/index.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register the menus routes.
|
|
33
|
+
*
|
|
34
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
35
|
+
* @param {{authenticate?: Function, requirePermission?: Function}} [opts]
|
|
36
|
+
* @returns {Promise<void>}
|
|
37
|
+
*/
|
|
38
|
+
export async function menusRoutes(fastify, opts = {}) {
|
|
39
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
40
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
41
|
+
|
|
42
|
+
const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
|
|
43
|
+
const canCreate = {preHandler: [authenticate, requirePermission('menus', 'create')]};
|
|
44
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('menus', 'update')]};
|
|
45
|
+
const canDelete = {preHandler: [authenticate, requirePermission('menus', 'delete')]};
|
|
46
|
+
|
|
47
|
+
fastify.get('/menus', canRead, async (request) => {
|
|
48
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
49
|
+
const all = await listMenus();
|
|
50
|
+
const filtered = [];
|
|
51
|
+
for (const m of all) {
|
|
52
|
+
// listMenus returns metadata only; getMenu returns full record with meta
|
|
53
|
+
const full = await getMenu(m.slug);
|
|
54
|
+
if (canSeeArtefact(request.user, full)) filtered.push(m);
|
|
55
|
+
}
|
|
56
|
+
return filtered;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
fastify.get('/menus/:slug', canRead, async (request, reply) => {
|
|
60
|
+
const menu = await getMenu(request.params.slug);
|
|
61
|
+
if (!menu) return reply.status(404).send({error: 'Menu not found'});
|
|
62
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
63
|
+
if (!canSeeArtefact(request.user, menu)) {
|
|
64
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
65
|
+
}
|
|
66
|
+
return menu;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
fastify.post('/menus', canCreate, async (request, reply) => {
|
|
70
|
+
try {
|
|
71
|
+
const menu = await createMenu(request.body || {});
|
|
72
|
+
await cache.invalidateTags([`menu:${menu.slug}`, 'nav']);
|
|
73
|
+
return reply.status(201).send(menu);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const status = /already exists/.test(err.message) ? 409 : 400;
|
|
76
|
+
return reply.status(status).send({error: err.message});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
fastify.put('/menus/:slug', canUpdate, async (request, reply) => {
|
|
81
|
+
try {
|
|
82
|
+
const menu = await updateMenu(request.params.slug, request.body || {});
|
|
83
|
+
await cache.invalidateTags([`menu:${menu.slug}`, 'nav']);
|
|
84
|
+
return menu;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const status = /not found/.test(err.message) ? 404 : 400;
|
|
87
|
+
return reply.status(status).send({error: err.message});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
fastify.delete('/menus/:slug', canDelete, async (request, reply) => {
|
|
92
|
+
const map = await getLocations();
|
|
93
|
+
const slot = Object.entries(map).find(([, slug]) => slug === request.params.slug)?.[0];
|
|
94
|
+
if (slot) {
|
|
95
|
+
return reply.status(409).send({
|
|
96
|
+
error: `Cannot delete — menu is mapped to slot "${slot}". Unmap it first.`
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const ok = await deleteMenu(request.params.slug);
|
|
100
|
+
if (!ok) return reply.status(404).send({error: 'Menu not found'});
|
|
101
|
+
await cache.invalidateTags([`menu:${request.params.slug}`, 'nav']);
|
|
102
|
+
return {success: true};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
fastify.post('/menus/:slug/duplicate', canCreate, async (request, reply) => {
|
|
106
|
+
try {
|
|
107
|
+
const copy = await duplicateMenu(request.params.slug);
|
|
108
|
+
await cache.invalidateTags([`menu:${copy.slug}`]);
|
|
109
|
+
return reply.status(201).send(copy);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const status = /not found/.test(err.message) ? 404 : 400;
|
|
112
|
+
return reply.status(status).send({error: err.message});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import {getConfig, saveConfig} from '../../config.js';
|
|
7
7
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
8
|
+
import * as cache from '../../services/cache/index.js';
|
|
8
9
|
|
|
9
10
|
export async function navigationRoutes(fastify) {
|
|
10
11
|
const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
|
|
@@ -35,6 +36,7 @@ export async function navigationRoutes(fastify) {
|
|
|
35
36
|
});
|
|
36
37
|
}
|
|
37
38
|
saveConfig('navigation', data);
|
|
39
|
+
await cache.invalidateTags(['nav']);
|
|
38
40
|
return { success: true };
|
|
39
41
|
});
|
|
40
42
|
}
|
|
@@ -22,7 +22,7 @@ export async function pagesRoutes(fastify) {
|
|
|
22
22
|
fastify.post('/pages/preview', canRead, async (request, reply) => {
|
|
23
23
|
const {markdown} = request.body;
|
|
24
24
|
if (typeof markdown !== 'string') return reply.status(400).send({error: 'markdown must be a string'});
|
|
25
|
-
const {html} = await parseMarkdown(`---\ntitle: preview\n---\n${markdown}
|
|
25
|
+
const {html} = await parseMarkdown(`---\ntitle: preview\n---\n${markdown}`, {user: request.user || null});
|
|
26
26
|
return {html};
|
|
27
27
|
});
|
|
28
28
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects API
|
|
3
|
+
*
|
|
4
|
+
* GET /api/projects — list visible projects
|
|
5
|
+
* GET /api/projects/:slug — full project
|
|
6
|
+
* POST /api/projects — create
|
|
7
|
+
* PUT /api/projects/:slug — edit (slug immutable)
|
|
8
|
+
* DELETE /api/projects/:slug — delete (409 if any artefacts tagged)
|
|
9
|
+
* GET /api/projects/:slug/artefacts — grouped artefacts
|
|
10
|
+
* POST /api/projects/:slug/untag-all — escape hatch before delete
|
|
11
|
+
*
|
|
12
|
+
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
authenticate as defaultAuthenticate,
|
|
16
|
+
requirePermission as defaultRequirePermission
|
|
17
|
+
} from '../../middleware/auth.js';
|
|
18
|
+
import {
|
|
19
|
+
createProject,
|
|
20
|
+
deleteProject,
|
|
21
|
+
getArtefactsForProject,
|
|
22
|
+
getProject,
|
|
23
|
+
listProjectsForUser,
|
|
24
|
+
untagAllForProject,
|
|
25
|
+
updateProject
|
|
26
|
+
} from '../../services/projects.js';
|
|
27
|
+
import * as cache from '../../services/cache/index.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register the projects routes.
|
|
31
|
+
*
|
|
32
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
33
|
+
* @param {{authenticate?: Function, requirePermission?: Function}} [opts]
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
export async function projectsRoutes(fastify, opts = {}) {
|
|
37
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
38
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
39
|
+
|
|
40
|
+
const canRead = {preHandler: [authenticate, requirePermission('projects', 'read')]};
|
|
41
|
+
const canCreate = {preHandler: [authenticate, requirePermission('projects', 'create')]};
|
|
42
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('projects', 'update')]};
|
|
43
|
+
const canDelete = {preHandler: [authenticate, requirePermission('projects', 'delete')]};
|
|
44
|
+
|
|
45
|
+
fastify.get('/projects', canRead, async (request) => {
|
|
46
|
+
return listProjectsForUser(request.user);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
fastify.get('/projects/:slug', canRead, async (request, reply) => {
|
|
50
|
+
const project = await getProject(request.params.slug);
|
|
51
|
+
if (!project) return reply.status(404).send({error: 'Project not found'});
|
|
52
|
+
return project;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
fastify.post('/projects', canCreate, async (request, reply) => {
|
|
56
|
+
try {
|
|
57
|
+
const project = await createProject(request.body || {});
|
|
58
|
+
await cache.invalidateTags(['projects']);
|
|
59
|
+
return reply.status(201).send(project);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const status = /already exists/.test(err.message) ? 409 : 400;
|
|
62
|
+
return reply.status(status).send({error: err.message});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
fastify.put('/projects/:slug', canUpdate, async (request, reply) => {
|
|
67
|
+
try {
|
|
68
|
+
const project = await updateProject(request.params.slug, request.body || {});
|
|
69
|
+
await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
|
|
70
|
+
return project;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const status = /not found/.test(err.message) ? 404 : 400;
|
|
73
|
+
return reply.status(status).send({error: err.message});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
|
|
78
|
+
const grouped = await getArtefactsForProject(request.params.slug);
|
|
79
|
+
const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
|
|
80
|
+
if (total > 0) {
|
|
81
|
+
return reply.status(409).send({
|
|
82
|
+
error: `Cannot delete — ${total} artefacts still tagged with this project`,
|
|
83
|
+
artefacts: Object.fromEntries(Object.entries(grouped).map(([k, v]) => [k, v.length]))
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const ok = await deleteProject(request.params.slug);
|
|
87
|
+
if (!ok) return reply.status(404).send({error: 'Project not found'});
|
|
88
|
+
await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
|
|
89
|
+
return {success: true};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
fastify.get('/projects/:slug/artefacts', canRead, async (request, reply) => {
|
|
93
|
+
if (!(await getProject(request.params.slug))) {
|
|
94
|
+
return reply.status(404).send({error: 'Project not found'});
|
|
95
|
+
}
|
|
96
|
+
return getArtefactsForProject(request.params.slug);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
|
|
100
|
+
if (!(await getProject(request.params.slug))) {
|
|
101
|
+
return reply.status(404).send({error: 'Project not found'});
|
|
102
|
+
}
|
|
103
|
+
const counts = await untagAllForProject(request.params.slug);
|
|
104
|
+
await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
|
|
105
|
+
return {success: true, untagged: counts};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffold API — list bundled recipes and apply one to create a working CRUD
|
|
3
|
+
* system (Collection + Form + Actions) in a single call.
|
|
4
|
+
*
|
|
5
|
+
* Admin-only — these endpoints can create resources, so they require the same
|
|
6
|
+
* permission tier as collection/form management. The `apply` endpoint is
|
|
7
|
+
* non-atomic across recipe pieces but returns a clear summary of what was
|
|
8
|
+
* created vs. skipped so the caller can clean up if needed.
|
|
9
|
+
*
|
|
10
|
+
* Endpoints:
|
|
11
|
+
* GET /api/scaffold/recipes — list bundled recipes (name, description, options)
|
|
12
|
+
* POST /api/scaffold/apply — apply one recipe with caller-provided option overrides
|
|
13
|
+
*/
|
|
14
|
+
import {applyRecipe, getRecipe, listRecipes} from '../../services/scaffolder.js';
|
|
15
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
16
|
+
|
|
17
|
+
export async function scaffoldRoutes(fastify) {
|
|
18
|
+
// Scaffolding creates collections + forms + actions, so we require both
|
|
19
|
+
// collections.create and forms.create permissions. The cheap check is to
|
|
20
|
+
// gate on collections.create — anyone with that should already be a
|
|
21
|
+
// privileged role; we add forms.create on top as a belt-and-braces.
|
|
22
|
+
const canScaffold = {preHandler: [authenticate, requirePermission('collections', 'create')]};
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* GET /api/scaffold/recipes
|
|
26
|
+
*
|
|
27
|
+
* Returns the list of bundled recipes available to scaffold. Each entry
|
|
28
|
+
* exposes just enough metadata for the picker UI to render: name,
|
|
29
|
+
* description, icon, and the options schema (with defaults and hints).
|
|
30
|
+
*
|
|
31
|
+
* Response: { recipes: [{slug, name, description, icon, options}, ...] }
|
|
32
|
+
*/
|
|
33
|
+
fastify.get('/scaffold/recipes', canScaffold, async () => {
|
|
34
|
+
const recipes = await listRecipes();
|
|
35
|
+
return { recipes };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* GET /api/scaffold/recipes/:slug
|
|
40
|
+
*
|
|
41
|
+
* Returns the full recipe document — useful for a preview view that wants
|
|
42
|
+
* to show the user exactly what will be created before they click Apply.
|
|
43
|
+
*/
|
|
44
|
+
fastify.get('/scaffold/recipes/:slug', canScaffold, async (request, reply) => {
|
|
45
|
+
const recipe = await getRecipe(request.params.slug);
|
|
46
|
+
if (!recipe) return reply.status(404).send({ error: 'Recipe not found' });
|
|
47
|
+
return recipe;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* POST /api/scaffold/apply
|
|
52
|
+
*
|
|
53
|
+
* Body: { recipe: <slug>, options?: { collectionSlug, formSlug, actionPrefix, ... } }
|
|
54
|
+
*
|
|
55
|
+
* Pre-flight: refuses if any target slug already exists (HTTP 409 with the
|
|
56
|
+
* conflict list in `conflicts`). On success, returns a summary describing
|
|
57
|
+
* what got created, what was skipped (e.g. Mongo-gated actions in file
|
|
58
|
+
* mode), and any non-fatal warnings.
|
|
59
|
+
*
|
|
60
|
+
* Response:
|
|
61
|
+
* { created: {collection, form, actions: [...]}, skipped: [...], warnings: [...], snippet: string|null }
|
|
62
|
+
*
|
|
63
|
+
* The `snippet` is a Markdown fragment the caller can insert at the
|
|
64
|
+
* cursor position in the page editor — typically the [form] + [collection]
|
|
65
|
+
* combination needed to see the new system on a page immediately.
|
|
66
|
+
*/
|
|
67
|
+
fastify.post('/scaffold/apply', canScaffold, async (request, reply) => {
|
|
68
|
+
const {recipe, options} = request.body || {};
|
|
69
|
+
if (!recipe || typeof recipe !== 'string') {
|
|
70
|
+
return reply.status(400).send({ error: 'recipe slug is required' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = await applyRecipe(recipe, {
|
|
75
|
+
options: options || {},
|
|
76
|
+
createdBy: request.user?.id || null
|
|
77
|
+
});
|
|
78
|
+
return result;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err.statusCode === 409 && Array.isArray(err.conflicts)) {
|
|
81
|
+
return reply.status(409).send({ error: err.message, conflicts: err.conflicts });
|
|
82
|
+
}
|
|
83
|
+
return reply.status(400).send({ error: err.message });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -10,6 +10,7 @@ import nodemailer from 'nodemailer';
|
|
|
10
10
|
import fs from 'fs/promises';
|
|
11
11
|
import path from 'path';
|
|
12
12
|
import {fileURLToPath} from 'url';
|
|
13
|
+
import * as cache from '../../services/cache/index.js';
|
|
13
14
|
|
|
14
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
|
|
@@ -64,6 +65,7 @@ export async function settingsRoutes(fastify) {
|
|
|
64
65
|
merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
|
|
65
66
|
}
|
|
66
67
|
saveConfig('site', merged);
|
|
68
|
+
await cache.invalidateTags(['site']);
|
|
67
69
|
return { success: true };
|
|
68
70
|
});
|
|
69
71
|
|
|
@@ -133,6 +135,7 @@ export async function settingsRoutes(fastify) {
|
|
|
133
135
|
return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
|
|
134
136
|
}
|
|
135
137
|
await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
|
|
138
|
+
await cache.invalidateTags(['site']);
|
|
136
139
|
return { success: true };
|
|
137
140
|
});
|
|
138
141
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin sidebar API
|
|
3
|
+
*
|
|
4
|
+
* GET /api/sidebar/registered-items — plugin-registered sidebar items
|
|
5
|
+
*
|
|
6
|
+
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
authenticate as defaultAuthenticate,
|
|
10
|
+
requirePermission as defaultRequirePermission
|
|
11
|
+
} from '../../middleware/auth.js';
|
|
12
|
+
import {getRegisteredSidebarItems} from '../../services/hooks.js';
|
|
13
|
+
|
|
14
|
+
export async function sidebarRoutes(fastify, opts = {}) {
|
|
15
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
16
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
17
|
+
|
|
18
|
+
const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
|
|
19
|
+
|
|
20
|
+
fastify.get('/sidebar/registered-items', canRead, async () => {
|
|
21
|
+
return getRegisteredSidebarItems();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -18,9 +18,13 @@ export async function usersRoutes(fastify) {
|
|
|
18
18
|
const canDelete = [authenticate, requirePermission('users', 'delete')];
|
|
19
19
|
|
|
20
20
|
// List all users
|
|
21
|
-
fastify.get('/users', {preHandler: canRead}, async () => {
|
|
21
|
+
fastify.get('/users', {preHandler: canRead}, async (request) => {
|
|
22
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
22
23
|
const [users, profiles] = await Promise.all([listUsers(), listProfiles()]);
|
|
23
|
-
|
|
24
|
+
// User records carry meta.project directly; filter against the raw record
|
|
25
|
+
return users
|
|
26
|
+
.filter(u => canSeeArtefact(request.user, u))
|
|
27
|
+
.map(u => ({...u, profile: profiles.get(u.id) || {}}));
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
// Get single user (admin, manager, or the user themselves)
|
|
@@ -55,12 +59,22 @@ export async function usersRoutes(fastify) {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const targetRole = role || 'user';
|
|
58
|
-
|
|
62
|
+
// Pass the full actor object so multi-role privilege is evaluated;
|
|
63
|
+
// for the prospective target we only have a primary role here.
|
|
64
|
+
if (!canManageUser(request.user, targetRole)) {
|
|
59
65
|
return reply.code(403).send({ error: 'You cannot create a user with that role' });
|
|
60
66
|
}
|
|
67
|
+
// Additional-roles privilege guard — actor must be able to manage EVERY role
|
|
68
|
+
// they're trying to assign, not just the primary.
|
|
69
|
+
const requestedAdditional = Array.isArray(request.body?.additionalRoles) ? request.body.additionalRoles : [];
|
|
70
|
+
for (const r of requestedAdditional) {
|
|
71
|
+
if (!canManageUser(request.user, r)) {
|
|
72
|
+
return reply.code(403).send({ error: `You cannot assign the additional role "${r}"` });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
61
75
|
|
|
62
76
|
try {
|
|
63
|
-
const user = await createUser({ name, email, password, role: targetRole });
|
|
77
|
+
const user = await createUser({ name, email, password, role: targetRole, additionalRoles: requestedAdditional });
|
|
64
78
|
return reply.status(201).send(user);
|
|
65
79
|
} catch (err) {
|
|
66
80
|
return reply.status(409).send({ error: err.message });
|
|
@@ -75,15 +89,26 @@ export async function usersRoutes(fastify) {
|
|
|
75
89
|
const target = await getUserById(id);
|
|
76
90
|
if (!target) return reply.status(404).send({ error: 'User not found' });
|
|
77
91
|
|
|
78
|
-
if (!canManageUser(actor
|
|
92
|
+
if (!canManageUser(actor, target)) {
|
|
79
93
|
return reply.code(403).send({ error: 'You cannot edit a user with that role' });
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
const updates = request.body || {};
|
|
83
97
|
// Prevent role escalation beyond what the actor can manage
|
|
84
|
-
if (updates.role && !canManageUser(actor
|
|
98
|
+
if (updates.role && !canManageUser(actor, updates.role)) {
|
|
85
99
|
return reply.code(403).send({ error: 'You cannot assign that role' });
|
|
86
100
|
}
|
|
101
|
+
// Same check for any additionalRoles being assigned
|
|
102
|
+
if (Array.isArray(updates.additionalRoles)) {
|
|
103
|
+
for (const r of updates.additionalRoles) {
|
|
104
|
+
if (!canManageUser(actor, r)) {
|
|
105
|
+
return reply.code(403).send({ error: `You cannot assign the additional role "${r}"` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Strip duplicates / primary collisions before persisting
|
|
109
|
+
const primary = updates.role || target.role;
|
|
110
|
+
updates.additionalRoles = updates.additionalRoles.filter(r => r && r !== primary);
|
|
111
|
+
}
|
|
87
112
|
|
|
88
113
|
// Strip profile from core updates and handle separately
|
|
89
114
|
const {profile, ...coreUpdates} = updates;
|
|
@@ -109,7 +134,7 @@ export async function usersRoutes(fastify) {
|
|
|
109
134
|
const target = await getUserById(id);
|
|
110
135
|
if (!target) return reply.status(404).send({ error: 'User not found' });
|
|
111
136
|
|
|
112
|
-
if (!canManageUser(actor
|
|
137
|
+
if (!canManageUser(actor, target)) {
|
|
113
138
|
return reply.code(403).send({ error: 'You cannot delete a user with that role' });
|
|
114
139
|
}
|
|
115
140
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import {createView, deleteView, executeView, getView, listViews, updateView} from '../../services/views.js';
|
|
17
17
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
18
18
|
import {getRoleLevel} from '../../services/roles.js';
|
|
19
|
+
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
19
20
|
|
|
20
21
|
export async function viewsRoutes(fastify) {
|
|
21
22
|
const canRead = {preHandler: [authenticate, requirePermission('views', 'read')]};
|
|
@@ -29,7 +30,10 @@ export async function viewsRoutes(fastify) {
|
|
|
29
30
|
|
|
30
31
|
fastify.get('/views', canRead, async (request, reply) => {
|
|
31
32
|
try {
|
|
32
|
-
|
|
33
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
34
|
+
const all = await listViews();
|
|
35
|
+
// listViews returns full records with meta inline — filter directly
|
|
36
|
+
return all.filter(v => canSeeArtefact(request.user, v));
|
|
33
37
|
} catch (err) {
|
|
34
38
|
return reply.status(503).send({ error: err.message });
|
|
35
39
|
}
|
|
@@ -60,6 +64,10 @@ export async function viewsRoutes(fastify) {
|
|
|
60
64
|
try {
|
|
61
65
|
const view = await getView(request.params.slug);
|
|
62
66
|
if (!view) return reply.status(404).send({ error: 'View not found' });
|
|
67
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
68
|
+
if (!canSeeArtefact(request.user, view)) {
|
|
69
|
+
return reply.status(403).send({ error: 'Access denied for this project' });
|
|
70
|
+
}
|
|
63
71
|
return view;
|
|
64
72
|
} catch (err) {
|
|
65
73
|
return reply.status(503).send({ error: err.message });
|
|
@@ -126,7 +134,7 @@ export async function viewsRoutes(fastify) {
|
|
|
126
134
|
|
|
127
135
|
const user = request.user;
|
|
128
136
|
const allowedRoles = view.access?.roles || ['admin', 'super-admin'];
|
|
129
|
-
const userLevel =
|
|
137
|
+
const userLevel = getEffectiveLevel(user);
|
|
130
138
|
const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
|
|
131
139
|
|
|
132
140
|
if (userLevel > minAllowed) {
|