domma-cms 0.76.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 +2 -2
- 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/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/form-editor-arrange.js +1 -0
- package/admin/js/lib/form-submissions-arrange.js +2 -0
- package/admin/js/lib/menu-editor-arrange.js +2 -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/project-page-arrange.js +1 -0
- package/admin/js/lib/role-editor-arrange.js +3 -0
- package/admin/js/lib/user-editor-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/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/form-editor.html +41 -26
- package/admin/js/templates/form-submissions.html +78 -26
- package/admin/js/templates/menu-editor.html +57 -17
- package/admin/js/templates/menu-locations.html +54 -11
- package/admin/js/templates/page-editor.html +47 -26
- package/admin/js/templates/plugin-code.html +91 -73
- 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/user-editor.html +150 -14
- 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/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/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/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/page-editor.js +26 -26
- package/admin/js/views/plugin-code.js +23 -4
- 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/user-editor.js +1 -1
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/view-preview.js +1 -1
- package/package.json +1 -1
- package/server/routes/api/dashboard.js +32 -4
- package/server/routes/api/forms.js +104 -13
- package/server/routes/api/users.js +12 -2
- package/server/routes/api/views.js +55 -1
- package/server/services/components.js +9 -0
- package/server/services/users.js +2 -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/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
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* GET /forms/:slug/submissions/export - admin: CSV export from collection
|
|
15
15
|
* DELETE /forms/:slug/submissions - admin: clear all submissions
|
|
16
16
|
* DELETE /forms/:slug/submissions/:id - admin: delete one submission
|
|
17
|
+
* POST /forms/:slug/submissions/:id/spam - admin: flag / unflag one submission as spam {spam}
|
|
18
|
+
* (every submissions route reads and writes submissionsCollectionFor(form))
|
|
17
19
|
* POST /forms/submit/:slug - public: accept submission
|
|
18
20
|
* POST /forms/test-email - admin: send test email
|
|
19
21
|
*/
|
|
@@ -29,7 +31,9 @@ import {
|
|
|
29
31
|
createEntry,
|
|
30
32
|
deleteEntry,
|
|
31
33
|
getCollection,
|
|
32
|
-
|
|
34
|
+
getEntry,
|
|
35
|
+
listEntries,
|
|
36
|
+
updateEntry
|
|
33
37
|
} from '../../services/collections.js';
|
|
34
38
|
import {getConfig} from '../../config.js';
|
|
35
39
|
import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
|
|
@@ -243,6 +247,30 @@ export function submissionsCollectionFor(form) {
|
|
|
243
247
|
return c?.enabled && c.slug ? c.slug : form?.slug;
|
|
244
248
|
}
|
|
245
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Whether an entry is one of THIS form's submissions. In the form's own
|
|
252
|
+
* collection, every entry is. In a collection its action shares with others
|
|
253
|
+
* (a signup form writing to `members`, two forms writing to `enquiries`), only
|
|
254
|
+
* the entries it made - submit stamps them `meta.source: 'form:<slug>'` - so
|
|
255
|
+
* that clearing, deleting, flagging, exporting or counting one form's
|
|
256
|
+
* submissions never touches the rest of that collection.
|
|
257
|
+
*/
|
|
258
|
+
export function isSubmissionOf(form, entry) {
|
|
259
|
+
if (submissionsCollectionFor(form) === form?.slug) return true;
|
|
260
|
+
return entry?.meta?.source === `form:${form?.slug}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function canSeeForm(user, form) {
|
|
264
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
265
|
+
return canSeeArtefact(user, form);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** This form's submissions, oldest first (listEntries order). */
|
|
269
|
+
async function submissionsOf(form) {
|
|
270
|
+
const r = await listEntries(submissionsCollectionFor(form), {limit: 0});
|
|
271
|
+
return (r.entries || []).filter(e => isSubmissionOf(form, e));
|
|
272
|
+
}
|
|
273
|
+
|
|
246
274
|
const DAY_MS = 86_400_000;
|
|
247
275
|
|
|
248
276
|
/**
|
|
@@ -256,8 +284,7 @@ const DAY_MS = 86_400_000;
|
|
|
256
284
|
*/
|
|
257
285
|
async function submissionStats(form, now = Date.now()) {
|
|
258
286
|
try {
|
|
259
|
-
const
|
|
260
|
-
const entries = r.entries || [];
|
|
287
|
+
const entries = await submissionsOf(form);
|
|
261
288
|
let week = 0;
|
|
262
289
|
let today = 0;
|
|
263
290
|
let last = null;
|
|
@@ -269,7 +296,7 @@ async function submissionStats(form, now = Date.now()) {
|
|
|
269
296
|
if (now - t <= DAY_MS) today++;
|
|
270
297
|
if (!last || at > last) last = at;
|
|
271
298
|
}
|
|
272
|
-
return {total:
|
|
299
|
+
return {total: entries.length, week, today, last};
|
|
273
300
|
} catch {
|
|
274
301
|
return {total: 0, week: 0, today: 0, last: null};
|
|
275
302
|
}
|
|
@@ -530,8 +557,10 @@ export async function formsRoutes(fastify) {
|
|
|
530
557
|
} catch {
|
|
531
558
|
return reply.status(404).send({ error: 'Form not found.' });
|
|
532
559
|
}
|
|
533
|
-
|
|
534
|
-
|
|
560
|
+
// The form's project scope, as the form routes check it: a user confined to
|
|
561
|
+
// other projects must not read or change this form's submissions.
|
|
562
|
+
if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
|
|
563
|
+
const targetSlug = submissionsCollectionFor(form);
|
|
535
564
|
|
|
536
565
|
// A form whose backing collection has not been provisioned yet simply
|
|
537
566
|
// has no submissions - return an empty list rather than a 404 so the
|
|
@@ -541,7 +570,7 @@ export async function formsRoutes(fastify) {
|
|
|
541
570
|
|
|
542
571
|
// listEntries returns { entries, total, page, limit } - unwrap to the
|
|
543
572
|
// array. Newest-first is the conventional display order.
|
|
544
|
-
const
|
|
573
|
+
const entries = await submissionsOf(form);
|
|
545
574
|
return [...entries].reverse();
|
|
546
575
|
});
|
|
547
576
|
|
|
@@ -556,10 +585,14 @@ export async function formsRoutes(fastify) {
|
|
|
556
585
|
} catch {
|
|
557
586
|
return reply.status(404).send({ error: 'Form not found.' });
|
|
558
587
|
}
|
|
588
|
+
// The form's project scope, as the form routes check it: a user confined to
|
|
589
|
+
// other projects must not read or change this form's submissions.
|
|
590
|
+
if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
|
|
591
|
+
// Where the list reads them: a form whose collection action writes elsewhere
|
|
592
|
+
// exported (and cleared, and deleted from) its own-slug collection instead.
|
|
559
593
|
let entries = [];
|
|
560
594
|
try {
|
|
561
|
-
|
|
562
|
-
entries = r.entries || [];
|
|
595
|
+
entries = await submissionsOf(form);
|
|
563
596
|
} catch {
|
|
564
597
|
// empty collection
|
|
565
598
|
}
|
|
@@ -580,10 +613,12 @@ export async function formsRoutes(fastify) {
|
|
|
580
613
|
} catch {
|
|
581
614
|
return reply.status(404).send({ error: 'Form not found.' });
|
|
582
615
|
}
|
|
616
|
+
// The form's project scope, as the form routes check it: a user confined to
|
|
617
|
+
// other projects must not read or change this form's submissions.
|
|
618
|
+
if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
|
|
583
619
|
let entries = [];
|
|
584
620
|
try {
|
|
585
|
-
|
|
586
|
-
entries = r.entries || [];
|
|
621
|
+
entries = await submissionsOf(form);
|
|
587
622
|
} catch {
|
|
588
623
|
// empty
|
|
589
624
|
}
|
|
@@ -597,8 +632,24 @@ export async function formsRoutes(fastify) {
|
|
|
597
632
|
// -----------------------------------------------------------------------
|
|
598
633
|
fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
|
|
599
634
|
const { slug } = request.params;
|
|
635
|
+
let form;
|
|
600
636
|
try {
|
|
601
|
-
await
|
|
637
|
+
form = await readForm(slug);
|
|
638
|
+
} catch {
|
|
639
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
640
|
+
}
|
|
641
|
+
// The form's project scope, as the form routes check it: a user confined to
|
|
642
|
+
// other projects must not read or change this form's submissions.
|
|
643
|
+
if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
|
|
644
|
+
const target = submissionsCollectionFor(form);
|
|
645
|
+
try {
|
|
646
|
+
// Its own collection: clear it. A shared one: delete only this form's
|
|
647
|
+
// entries (one at a time - parallel writes to a file collection lose updates).
|
|
648
|
+
if (target === form.slug) {
|
|
649
|
+
await clearEntries(target);
|
|
650
|
+
} else {
|
|
651
|
+
for (const e of await submissionsOf(form)) await deleteEntry(target, e.id);
|
|
652
|
+
}
|
|
602
653
|
} catch {
|
|
603
654
|
return reply.status(404).send({ error: 'Collection not found for this form.' });
|
|
604
655
|
}
|
|
@@ -610,14 +661,54 @@ export async function formsRoutes(fastify) {
|
|
|
610
661
|
// -----------------------------------------------------------------------
|
|
611
662
|
fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
|
|
612
663
|
const { slug, id } = request.params;
|
|
664
|
+
let form;
|
|
613
665
|
try {
|
|
614
|
-
await
|
|
666
|
+
form = await readForm(slug);
|
|
667
|
+
} catch {
|
|
668
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
669
|
+
}
|
|
670
|
+
// The form's project scope, as the form routes check it: a user confined to
|
|
671
|
+
// other projects must not read or change this form's submissions.
|
|
672
|
+
if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
|
|
673
|
+
const target = submissionsCollectionFor(form);
|
|
674
|
+
try {
|
|
675
|
+
if (!isSubmissionOf(form, await getEntry(target, id))) return reply.status(404).send({ error: 'Submission not found.' });
|
|
676
|
+
await deleteEntry(target, id);
|
|
615
677
|
} catch {
|
|
616
678
|
return reply.status(404).send({ error: 'Submission not found.' });
|
|
617
679
|
}
|
|
618
680
|
return { ok: true };
|
|
619
681
|
});
|
|
620
682
|
|
|
683
|
+
// -----------------------------------------------------------------------
|
|
684
|
+
// POST /forms/:slug/submissions/:id/spam - flag or unflag one submission.
|
|
685
|
+
// Merged server-side (updateEntry replaces `data` wholesale), in the field
|
|
686
|
+
// the collections spam route and the mark-as-spam context action use: `spam`.
|
|
687
|
+
// -----------------------------------------------------------------------
|
|
688
|
+
fastify.post('/forms/:slug/submissions/:id/spam', canUpdate, async (request, reply) => {
|
|
689
|
+
const { slug, id } = request.params;
|
|
690
|
+
let form;
|
|
691
|
+
try {
|
|
692
|
+
form = await readForm(slug);
|
|
693
|
+
} catch {
|
|
694
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
695
|
+
}
|
|
696
|
+
// The form's project scope, as the form routes check it: a user confined to
|
|
697
|
+
// other projects must not read or change this form's submissions.
|
|
698
|
+
if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
|
|
699
|
+
const target = submissionsCollectionFor(form);
|
|
700
|
+
const value = request.body?.spam !== false;
|
|
701
|
+
try {
|
|
702
|
+
const existing = await getEntry(target, id);
|
|
703
|
+
if (!existing || !isSubmissionOf(form, existing)) return reply.status(404).send({ error: 'Submission not found.' });
|
|
704
|
+
const entry = await updateEntry(target, id, {...(existing.data || {}), spam: value});
|
|
705
|
+
hooks.emit('collection:entryUpdated', {slug: target, entryId: entry.id, data: entry.data, userId: request.user?.id || null});
|
|
706
|
+
return {entry, spam: value};
|
|
707
|
+
} catch (err) {
|
|
708
|
+
return reply.status(/not found/i.test(err.message) ? 404 : 400).send({ error: err.message });
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
621
712
|
/*
|
|
622
713
|
* POST /forms/submit/:slug - public form submission.
|
|
623
714
|
*
|
|
@@ -119,12 +119,22 @@ export async function usersRoutes(fastify) {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
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;
|
|
122
126
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
});
|
|
125
133
|
} catch (err) {
|
|
126
134
|
return reply.status(409).send({ error: err.message });
|
|
127
135
|
}
|
|
136
|
+
if (profile && typeof profile === 'object' && !Array.isArray(profile)) await updateProfile(user.id, profile);
|
|
137
|
+
return reply.status(201).send(user);
|
|
128
138
|
});
|
|
129
139
|
|
|
130
140
|
// Update user
|
|
@@ -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
|
// -------------------------------------------------------------------------
|
|
@@ -303,6 +303,15 @@ export async function getComponent(name) {
|
|
|
303
303
|
} catch { /* return malformed source for editing; UI flags it */ }
|
|
304
304
|
return {name, source, props, bundled, ...(meta && {meta})};
|
|
305
305
|
} catch (err) {
|
|
306
|
+
// A plugin's component has no file: return its source, read-only
|
|
307
|
+
// (saveComponent/deleteComponent refuse it as PLUGIN_OWNED). The list's
|
|
308
|
+
// "View source" opened the editor on it and got a 404.
|
|
309
|
+
const pc = err.code === 'ENOENT' && getPluginComponents().find(c => c.name === name);
|
|
310
|
+
if (pc) {
|
|
311
|
+
let props = {};
|
|
312
|
+
try { props = validatePropsJson(parseDmcSource(pc.source).props); } catch { /* flagged by the UI */ }
|
|
313
|
+
return {name, source: pc.source, props, bundled: true, origin: 'plugin', readOnly: true};
|
|
314
|
+
}
|
|
306
315
|
if (err.code === 'ENOENT') {
|
|
307
316
|
const nf = new Error('Component not found');
|
|
308
317
|
nf.code = 'ENOENT';
|
package/server/services/users.js
CHANGED
|
@@ -124,7 +124,7 @@ export async function getUserByEmail(email) {
|
|
|
124
124
|
* @param {object} data - { name, email, password, role }
|
|
125
125
|
* @returns {Promise<object>} Created user (password stripped)
|
|
126
126
|
*/
|
|
127
|
-
export async function createUser({ name, email, password, role = 'user', additionalRoles = [], meta, projects }) {
|
|
127
|
+
export async function createUser({ name, email, password, role = 'user', additionalRoles = [], meta, projects, isActive = true }) {
|
|
128
128
|
const existing = await getUserByEmail(email);
|
|
129
129
|
if (existing) throw new Error('A user with that email already exists');
|
|
130
130
|
|
|
@@ -144,7 +144,7 @@ export async function createUser({ name, email, password, role = 'user', additio
|
|
|
144
144
|
// Project access-scope: when non-empty, the user is restricted to artefacts
|
|
145
145
|
// tagged with one of these project slugs (see canSeeArtefact in projects.js).
|
|
146
146
|
projects: Array.isArray(projects) ? projects.filter(Boolean) : [],
|
|
147
|
-
isActive:
|
|
147
|
+
isActive: isActive !== false,
|
|
148
148
|
createdAt: now,
|
|
149
149
|
updatedAt: now,
|
|
150
150
|
lastLogin: null,
|
package/server/services/views.js
CHANGED
|
@@ -452,7 +452,21 @@ async function executeFileView(view, { page, limit, user }) {
|
|
|
452
452
|
export async function executeView(slug, {page = 1, limit = 25, user = null} = {}) {
|
|
453
453
|
const view = await getView(slug);
|
|
454
454
|
if (!view) throw new Error(`View "${slug}" not found`);
|
|
455
|
+
return executeViewConfig(view, {page, limit, user});
|
|
456
|
+
}
|
|
455
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Execute a view config that need not be saved - the view editor's live
|
|
460
|
+
* Results run the pipeline being edited. Same validation and the same two
|
|
461
|
+
* engines (native aggregation for a MongoDB source, the JS evaluator
|
|
462
|
+
* otherwise) as executeView.
|
|
463
|
+
*
|
|
464
|
+
* @param {{connection?: string, pipeline: {source: string, stages?: object[]}, access?: object}} view
|
|
465
|
+
* @param {{page?: number, limit?: number, user?: object|null}} [opts]
|
|
466
|
+
* @returns {Promise<{ results: object[], total: number, page: number, limit: number }>}
|
|
467
|
+
*/
|
|
468
|
+
export async function executeViewConfig(view, {page = 1, limit = 25, user = null} = {}) {
|
|
469
|
+
if (!view?.pipeline?.source) throw new Error('pipeline.source is required');
|
|
456
470
|
validateStages(view.pipeline?.stages || []);
|
|
457
471
|
|
|
458
472
|
// Dispatch on the SOURCE collection's storage adapter. A mongodb-declared
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
<div class="dash-card-header">
|
|
2
|
-
<h3>Response Cache</h3>
|
|
3
|
-
<label class="dash-cache-toggle" title="Enable / disable cache">
|
|
4
|
-
<input type="checkbox" data-field="toggle">
|
|
5
|
-
<span data-field="toggle-label">…</span>
|
|
6
|
-
</label>
|
|
7
|
-
</div>
|
|
8
|
-
<div class="dash-cache-grid">
|
|
9
|
-
<div class="dash-cache-stat">
|
|
10
|
-
<div class="dash-cache-stat-label">Hit rate</div>
|
|
11
|
-
<div class="dash-cache-stat-value" data-field="hit-rate">-</div>
|
|
12
|
-
<div class="dash-cache-stat-sub" data-field="hits-misses">no traffic yet</div>
|
|
13
|
-
</div>
|
|
14
|
-
<div class="dash-cache-stat">
|
|
15
|
-
<div class="dash-cache-stat-label">Entries</div>
|
|
16
|
-
<div class="dash-cache-stat-value" data-field="size">-</div>
|
|
17
|
-
<div class="dash-cache-stat-sub" data-field="size-bar"><span data-field="size-fill"></span></div>
|
|
18
|
-
</div>
|
|
19
|
-
<div class="dash-cache-stat">
|
|
20
|
-
<div class="dash-cache-stat-label">Writes</div>
|
|
21
|
-
<div class="dash-cache-stat-value" data-field="writes">0</div>
|
|
22
|
-
<div class="dash-cache-stat-sub" data-field="invalidations">0 invalidations</div>
|
|
23
|
-
</div>
|
|
24
|
-
<div class="dash-cache-stat">
|
|
25
|
-
<div class="dash-cache-stat-label">Last cleared</div>
|
|
26
|
-
<div class="dash-cache-stat-value" data-field="last-cleared">never</div>
|
|
27
|
-
<div class="dash-cache-stat-sub" data-field="driver">driver: -</div>
|
|
28
|
-
</div>
|
|
29
|
-
</div>
|
|
30
|
-
<div class="dash-cache-actions">
|
|
31
|
-
<button class="btn btn-sm btn-ghost" data-field="clear-btn"><span data-icon="trash"></span> Clear cache</button>
|
|
32
|
-
</div>
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
<h3>User journeys - last 7 days</h3>
|
|
2
|
-
<div class="dash-journey-grid">
|
|
3
|
-
<div>
|
|
4
|
-
<h4>Top entry pages</h4>
|
|
5
|
-
<div data-field="entry"></div>
|
|
6
|
-
</div>
|
|
7
|
-
<div>
|
|
8
|
-
<h4>Top exit pages</h4>
|
|
9
|
-
<div data-field="exit"></div>
|
|
10
|
-
</div>
|
|
11
|
-
<div>
|
|
12
|
-
<h4>Top paths</h4>
|
|
13
|
-
<div data-field="paths"></div>
|
|
14
|
-
</div>
|
|
15
|
-
</div>
|
|
16
|
-
<p class="dash-bounce">Bounce rate: <strong data-field="bounce">-</strong></p>
|
|
17
|
-
<p class="dash-empty" hidden>No journey data yet - encourage some traffic and check back.</p>
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<div class="dash-card dash-kpi">
|
|
2
|
-
<span class="dash-kpi-icon" data-icon="trending-up"></span>
|
|
3
|
-
<div>
|
|
4
|
-
<div class="dash-kpi-value" data-field="hits-today">-</div>
|
|
5
|
-
<div class="dash-kpi-label">Hits today</div>
|
|
6
|
-
<div class="dash-kpi-delta" data-field="hits-delta"></div>
|
|
7
|
-
</div>
|
|
8
|
-
</div>
|
|
9
|
-
<div class="dash-card dash-kpi">
|
|
10
|
-
<span class="dash-kpi-icon" data-icon="users"></span>
|
|
11
|
-
<div>
|
|
12
|
-
<div class="dash-kpi-value" data-field="active-sessions">-</div>
|
|
13
|
-
<div class="dash-kpi-label">Active sessions (5m)</div>
|
|
14
|
-
</div>
|
|
15
|
-
</div>
|
|
16
|
-
<div class="dash-card dash-kpi">
|
|
17
|
-
<span class="dash-kpi-icon" data-icon="inbox"></span>
|
|
18
|
-
<div>
|
|
19
|
-
<div class="dash-kpi-value" data-field="submissions-week">-</div>
|
|
20
|
-
<div class="dash-kpi-label">Submissions this week</div>
|
|
21
|
-
</div>
|
|
22
|
-
</div>
|
|
23
|
-
<div class="dash-card dash-kpi">
|
|
24
|
-
<span class="dash-kpi-icon" data-icon="activity"></span>
|
|
25
|
-
<div>
|
|
26
|
-
<div class="dash-kpi-value">
|
|
27
|
-
<span class="dash-health-pill" data-field="health-pill" data-level="ok">
|
|
28
|
-
<span data-icon="circle"></span>
|
|
29
|
-
<span data-field="health-label">OK</span>
|
|
30
|
-
</span>
|
|
31
|
-
</div>
|
|
32
|
-
<div class="dash-kpi-label">Site health</div>
|
|
33
|
-
</div>
|
|
34
|
-
</div>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{blockEditorView as r}from"./block-editor.js";import{createSimpleEditor as u}from"../lib/simple-editor.js";const p=r.onMount;r.onMount=async function(e){await p.call(this,e);const n=e.find(".editor-pane--write").get(0),l=e.find("#block-line-numbers").get(0),t=e.find("#block-content").get(0);if(!n||!t||n.dataset.dmEnhanced==="1")return;n.dataset.dmEnhanced="1",l&&(l.style.display="none"),t.style.display="none",n.style.display="block",n.style.position="relative",n.style.overflow="hidden";const i=document.createElement("div");i.style.cssText="width:100%;height:100%;",n.appendChild(i);const a=u(i,{initialValue:t.value,onChange:o=>{t.value!==o&&(t.value=o,t.dispatchEvent(new Event("input",{bubbles:!0})))}}),d=Object.getPrototypeOf(t),c=Object.getOwnPropertyDescriptor(d,"value");Object.defineProperty(t,"value",{configurable:!0,get(){return c.get.call(this)},set(o){c.set.call(this,o),a.getValue()!==o&&a.setValue(o??"")}});const s=e.find(".card.editor-card").get(0);s&&(s.style.height="min(78vh, 900px)",s.style.minHeight="520px"),g(e)};const f={icon:"box","facebook-icon":"facebook","instagram-icon":"instagram"};function g(e){const n=e.get?e.get(0):e;if(!n)return;const l=n.querySelectorAll("input[data-placeholder]");for(const t of l){const i=(t.dataset.placeholder||"").toLowerCase(),a=f[i];a&&(t.value&&!/^Sample /i.test(t.value)||(t.value=a,t.dispatchEvent(new Event("input",{bubbles:!0}))))}}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export function escapeHtml(t){return t==null?"":String(t).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}export function safeHref(t){if(t==null)return"#";const r=String(t).trim();if(r==="")return"#";if(r.startsWith("/"))return escapeHtml(r);const e=r.toLowerCase();return e.startsWith("http://")||e.startsWith("https://")||e.startsWith("mailto:")?escapeHtml(r):"#"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{escapeHtml as s}from"../lib/escape.js";const p={edit:"edited",publish:"published",form:"received submission"};function f(e,n){e&&(e.innerHTML=n)}export default function m(e,n){const r=Array.isArray(n?.activity)?n.activity:[];r.length===0?e.find(".dash-empty").removeAttr("hidden"):e.find(".dash-empty").attr("hidden","hidden");const i=r.map(t=>{const o=t.at?D(t.at).fromNow():"",a=t.target||t.form||"",d=t.actor?"<strong>"+s(t.actor)+"</strong> ":"",c=s(p[t.type]||t.type||"");return'<div class="dash-spike-row"><span>'+d+c+" <code>"+s(a)+"</code></span><span>"+s(o)+"</span></div>"});f(e.find('[data-field="rows"]').get(0),i.join(""))}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{apiRequest as r}from"../../../api.js";function h(t){if(!t)return"never";const e=Date.now()-new Date(t).getTime();if(e<0)return"just now";const a=Math.floor(e/1e3);if(a<60)return`${a}s ago`;const i=Math.floor(a/60);if(i<60)return`${i}m ago`;const n=Math.floor(i/60);return n<24?`${n}h ago`:`${Math.floor(n/24)}d ago`}function d(t,e){const a=e.hits+e.misses,i=e.hitRate;t.find('[data-field="hit-rate"]').text(i===null?"-":(i*100).toFixed(1)+"%"),t.find('[data-field="hits-misses"]').text(a===0?"no traffic yet":`${e.hits.toLocaleString()} hits \xB7 ${e.misses.toLocaleString()} misses`);const n=e.size??0,c=e.maxItems??0;t.find('[data-field="size"]').text(c?`${n} / ${c}`:String(n));const l=c?Math.min(100,n/c*100):0,o=t.find('[data-field="size-fill"]').get(0);o&&(o.style.width=l+"%"),t.find('[data-field="writes"]').text(e.writes.toLocaleString()),t.find('[data-field="invalidations"]').text(`${e.invalidations.toLocaleString()} invalidations`),t.find('[data-field="last-cleared"]').text(h(e.lastClearedAt)),t.find('[data-field="driver"]').text("driver: "+e.driver);const f=t.find('[data-field="toggle"]').get(0);f&&(f.checked=!!e.enabled),t.find('[data-field="toggle-label"]').text(e.enabled?"On":"Off")}async function s(t){try{const e=await r("/cache/status");d(t,e),t.data("cache-stats",e)}catch(e){console.warn("[cache widget] failed to refresh:",e)}}export default async function g(t){await s(t);const e=t.find('[data-field="toggle"]');e.on("change",async()=>{const a=e.get(0).checked;try{const i=await r("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:a})});d(t,i),E.toast(`Cache ${i.enabled?"enabled":"disabled"}.`,{type:"success"})}catch(i){e.get(0).checked=!a,E.toast(i.message||"Failed to toggle cache.",{type:"error"})}}),t.find('[data-field="clear-btn"]').on("click",async()=>{if(await E.confirm("Clear the entire response cache?"))try{const a=await r("/cache/clear",{method:"POST"});d(t,a),E.toast("Cache cleared.",{type:"success"})}catch(a){E.toast(a.message||"Failed to clear cache.",{type:"error"})}}),window.Domma&&Domma.icons&&Domma.icons.scan()}export{s as refreshCacheWidget};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
const p={ok:"check-circle",warn:"warning",fail:"x-circle"};export default function h(o,s){const r=s?.health?.checks||[],l=o.find('[data-field="rows"]');l.empty();const a=l.get(0);if(a){for(const e of r){const n=document.createElement("div");n.className="dash-spike-row dash-health-pill",n.setAttribute("data-level",e.level);const c=document.createElement("span"),i=document.createElement("span");i.setAttribute("data-icon",p[e.level]||"circle"),c.appendChild(i),c.appendChild(document.createTextNode(" "+(e.label||e.id)));const d=document.createElement("span");if(d.textContent=String(e.value),n.appendChild(c),n.appendChild(d),a.appendChild(n),Array.isArray(e.detail)&&e.detail.length>0){const t=document.createElement("div");t.className="dash-health-detail",t.style.fontSize="12px",t.style.opacity="0.75",t.style.padding="4px 0 8px",t.textContent=e.detail.slice(0,5).join(", "),a.appendChild(t)}}window.Domma&&Domma.icons&&Domma.icons.scan()}}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{escapeHtml as r}from"../lib/escape.js";function s(n,a){return n.map(e=>'<div class="dash-spike-row">'+a(e)+"</div>").join("")}function i(n,a){n&&(n.innerHTML=a)}export default function f(n,a){const e=a?.journeys,d=n.find(".dash-empty"),o=n.find('[data-field="entry"]'),p=n.find('[data-field="exit"]'),u=n.find('[data-field="paths"]'),c=n.find('[data-field="bounce"]');if(!e||!e.totalSessions){d.removeAttr("hidden"),o.empty(),p.empty(),u.empty(),c.text("-");return}d.attr("hidden","hidden"),i(o.get(0),s(e.entry||[],t=>"<span>"+r(t.url)+"</span><span>"+(Number(t.count)|0)+"</span>")),i(p.get(0),s(e.exit||[],t=>"<span>"+r(t.url)+"</span><span>"+(Number(t.count)|0)+"</span>")),i(u.get(0),s(e.paths||[],t=>"<span>"+r(t.from)+" → "+r(t.to)+"</span><span>"+(Number(t.count)|0)+"</span>")),c.text((Number(e.bounceRate)*100).toFixed(1)+"%")}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export default function n(e,t){const d=t?.traffic?.today??0,a=t?.traffic?.deltaPct;e.find('[data-field="hits-today"]').text(d);const s=e.find('[data-field="hits-delta"]');a==null?s.text("").removeClass("up down"):a>=0?s.text("+"+a+"% vs yesterday").removeClass("down").addClass("up"):s.text(a+"% vs yesterday").removeClass("up").addClass("down"),e.find('[data-field="active-sessions"]').text(t?.realtime?.activeSessions??0),e.find('[data-field="submissions-week"]').text(t?._submissionsWeek??"-");const i=t?.health?.status||"ok",l={ok:"OK",warn:"Attention",fail:"Failing"};e.find('[data-field="health-pill"]').attr("data-level",i),e.find('[data-field="health-label"]').text(l[i])}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import{escapeHtml as a,safeHref as d}from"../lib/escape.js";export default function p(i,r){const t=Array.isArray(r?.spikes)?r.spikes:[];t.length===0?i.find(".dash-empty").removeAttr("hidden"):i.find(".dash-empty").attr("hidden","hidden");const s=t.map(e=>`
|
|
2
|
-
<div class="dash-spike-row">
|
|
3
|
-
<span><a href="${d(e.url)}">${a(e.url)}</a></span>
|
|
4
|
-
<span><strong>${Number(e.hits)|0}</strong> \xB7 \xD7${Number(e.ratio)||0}</span>
|
|
5
|
-
</div>
|
|
6
|
-
`).join(""),n=i.find('[data-field="spike-list"]').get(0);n&&(n.innerHTML=s)}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{escapeHtml as l,safeHref as i}from"../lib/escape.js";function c(n,s,e){if(s=s||80,e=e||24,!n||n.length===0)return"";const a=n.map(p=>Number(p)||0),o=Math.max.apply(null,a.concat([1])),t=s/Math.max(a.length-1,1),r=a.map((p,d)=>(d*t).toFixed(1)+","+(e-p/o*e).toFixed(1)).join(" ");return'<svg class="dash-spark" viewBox="0 0 '+s+" "+e+'" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" stroke-width="1.5" points="'+r+'"/></svg>'}export default function m(n,s){const e=Array.isArray(s?.topPages)?s.topPages:[];e.length===0?n.find(".dash-empty").removeAttr("hidden"):n.find(".dash-empty").attr("hidden","hidden");const a=e.map(t=>{const r=t.deltaPct==null?"":'<span class="'+(t.deltaPct>=0?"up":"down")+' dash-kpi-delta">'+(t.deltaPct>=0?"+":"")+(Number(t.deltaPct)||0)+"%</span>";return'<div class="dash-spike-row"><span><a href="'+i(t.url)+'">'+l(t.url)+"</a> "+c(t.spark)+"</span><span><strong>"+(Number(t.hits)|0)+"</strong> "+r+"</span></div>"}).join(""),o=n.find('[data-field="rows"]').get(0);o&&(o.innerHTML=a)}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
const p="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js";let s=null;function u(){return window.Chart?Promise.resolve(window.Chart):s||(s=new Promise((r,n)=>{const e=document.createElement("script");e.src=p,e.onload=()=>r(window.Chart),e.onerror=n,document.head.appendChild(e)}),s)}let i=null;export default async function m(r,n){const e=await u(),o=30,a=[];for(let t=o-1;t>=0;t-=1)a.push({label:D().subtract(t,"days").format("MM-DD"),hits:0});if(Array.isArray(n?.topPages))for(const t of n.topPages){if(!Array.isArray(t.spark))continue;const l=o-t.spark.length;t.spark.forEach((f,h)=>{a[l+h].hits+=f})}const d=a.every(t=>t.hits===0);d?r.find(".dash-empty").removeAttr("hidden"):r.find(".dash-empty").attr("hidden","hidden");const c=r.find("#dash-traffic-canvas").get(0);c&&(i&&(i.destroy(),i=null),!d&&(i=new e(c,{type:"line",data:{labels:a.map(t=>t.label),datasets:[{data:a.map(t=>t.hits),borderColor:"rgba(37, 99, 235, 1)",backgroundColor:"rgba(37, 99, 235, 0.15)",fill:!0,tension:.3,pointRadius:0}]},options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1}},scales:{x:{ticks:{maxTicksLimit:8}},y:{beginAtZero:!0}}}})))}
|