domma-cms 1.1.0 → 1.3.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/admin/css/admin.css +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/dashboard-board-arrange.js +1 -0
- package/admin/js/lib/sidebar-frequent.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/widget-render.js +1 -0
- package/admin/js/templates/docs/usage-dashboard.html +82 -0
- package/admin/js/templates/documentation.html +4 -0
- package/admin/js/templates/my-dashboard.html +3 -0
- package/admin/js/views/dashboard-board.js +63 -0
- package/admin/js/views/dashboard.js +2 -2
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/my-dashboard.js +1 -0
- package/package.json +1 -1
- package/server/routes/api/auth.js +5 -1
- package/server/routes/api/dashboard.js +85 -0
- package/server/routes/api/sidebar.js +9 -0
- package/server/routes/api/widgets.js +181 -0
- package/server/server.js +3 -0
- package/server/services/dashboardSettings.js +111 -0
- package/server/services/docs.js +1 -0
- package/server/services/plugins.js +15 -0
- package/server/services/siteGitignore.js +3 -0
- package/server/services/widgets.js +325 -0
|
@@ -12,6 +12,12 @@ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin
|
|
|
12
12
|
import {getHealth} from '../../services/health.js';
|
|
13
13
|
import {listVersions} from '../../services/versions.js';
|
|
14
14
|
import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
|
|
15
|
+
import {canAny as widgetCanAny, isDashboardPro} from '../../services/widgets.js';
|
|
16
|
+
import {getPermissionsForRoles} from '../../services/roles.js';
|
|
17
|
+
import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
|
|
18
|
+
import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
|
|
19
|
+
// The fixed Dashboard's own rules, so a widget says exactly what its card says.
|
|
20
|
+
import {BADGE_SOURCES, activityRows, attentionFrom, chartSeries} from '../../../admin/js/lib/dashboard-arrange.js';
|
|
15
21
|
|
|
16
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
23
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
@@ -290,6 +296,14 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
290
296
|
|
|
291
297
|
fastify.get('/dashboard/summary', guard, async (request) => {
|
|
292
298
|
const lite = request.query.lite === '1' || request.query.lite === 'true';
|
|
299
|
+
return buildSummary(lite);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The summary the fixed Dashboard reads - and, piece by piece, core's own
|
|
304
|
+
* widgets on the Dashboard Pro board (GET /dashboard/widget/:name below).
|
|
305
|
+
*/
|
|
306
|
+
async function buildSummary(lite) {
|
|
293
307
|
const warnings = [];
|
|
294
308
|
const safe = async (label, fn) => {
|
|
295
309
|
try { return await fn(); }
|
|
@@ -374,5 +388,76 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
374
388
|
const activity = (await safe('activity', () => buildActivity())) || [];
|
|
375
389
|
|
|
376
390
|
return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Core's own widgets (services/widgets.js CORE_WIDGETS), each in its kind's
|
|
395
|
+
* shape. Dashboard Pro only - a site without it answers 404, as for every
|
|
396
|
+
* widget route. "Needs attention" asks the same sidebar badges the fixed
|
|
397
|
+
* Dashboard does, as the signed-in user, so nobody sees a Tool they cannot open.
|
|
398
|
+
*/
|
|
399
|
+
fastify.get('/dashboard/widget/:name', {preHandler: [authenticate]}, async (request, reply) => {
|
|
400
|
+
if (!isDashboardPro()) return reply.status(404).send({error: 'Not found'});
|
|
401
|
+
const name = String(request.params.name || '');
|
|
402
|
+
const admin = getEffectiveLevel(request.user) <= 1;
|
|
403
|
+
if (name !== 'attention' && !admin) return reply.status(403).send({error: 'Administrators only'});
|
|
404
|
+
const fmtN = (n) => Number(n || 0).toLocaleString('en-GB');
|
|
405
|
+
|
|
406
|
+
if (name === 'attention') {
|
|
407
|
+
const permissions = getPermissionsForRoles(getEffectiveRoles(request.user), getEffectiveRegistry().map(r => r.key));
|
|
408
|
+
const want = BADGE_SOURCES.filter(src => widgetCanAny(permissions, src.permission));
|
|
409
|
+
const got = await Promise.all(want.map(src => fastify.inject({
|
|
410
|
+
method: 'GET', url: `/api${src.countUrl}`,
|
|
411
|
+
headers: {authorization: request.headers.authorization || ''}
|
|
412
|
+
}).then(r => (r.statusCode === 200 ? r.json() : null)).catch(() => null)));
|
|
413
|
+
const badges = Object.fromEntries(want.map((src, i) => [src.key, got[i]]).filter(([, b]) => b));
|
|
414
|
+
const checks = admin ? ((await getHealth().catch(() => null))?.checks || []) : [];
|
|
415
|
+
const rows = attentionFrom(badges, checks);
|
|
416
|
+
return {
|
|
417
|
+
items: rows.map(r => ({text: `${r.title}: ${r.text}`, meta: r.meta, href: r.href, tone: r.tone})),
|
|
418
|
+
empty: 'Nothing needs you right now.'
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const s = await buildSummary(false);
|
|
423
|
+
if (name === 'traffic') {
|
|
424
|
+
const c = chartSeries(s.traffic?.series);
|
|
425
|
+
return {labels: c.labels, values: c.values, label: 'Page views'};
|
|
426
|
+
}
|
|
427
|
+
if (name === 'visitors') {
|
|
428
|
+
const delta = s.traffic?.deltaPct;
|
|
429
|
+
return {
|
|
430
|
+
value: fmtN(s.realtime?.activeSessions), label: 'on the site now',
|
|
431
|
+
sub: `${fmtN(s.traffic?.today)} page views today${delta == null ? '' : ` (${delta >= 0 ? '+' : ''}${Number(delta).toFixed(0)}% on yesterday)`}`
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (name === 'top-pages') {
|
|
435
|
+
return {
|
|
436
|
+
items: (s.topPages || []).map(p => ({
|
|
437
|
+
text: p.url, value: fmtN(p.hits),
|
|
438
|
+
meta: p.deltaPct == null ? '' : `${p.deltaPct >= 0 ? '+' : ''}${Number(p.deltaPct).toFixed(0)}% on the week before`,
|
|
439
|
+
tone: p.deltaPct == null ? '' : (p.deltaPct >= 0 ? 'ok' : 'danger')
|
|
440
|
+
})),
|
|
441
|
+
empty: 'No page views in the last week.'
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (name === 'activity') {
|
|
445
|
+
return {
|
|
446
|
+
items: activityRows(s.activity || []).map(a => ({text: a.text, meta: a.meta, href: a.href})),
|
|
447
|
+
empty: 'Nothing has changed lately.'
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (name === 'health') {
|
|
451
|
+
const LEVEL = {ok: 'ok', warn: 'warn', fail: 'danger'};
|
|
452
|
+
return {
|
|
453
|
+
items: (s.health?.checks || []).map(c => ({
|
|
454
|
+
text: String(c.label || c.id), value: String(c.value ?? ''),
|
|
455
|
+
meta: Array.isArray(c.detail) ? c.detail.slice(0, 3).join(', ') : '',
|
|
456
|
+
tone: LEVEL[c.level] || 'warn'
|
|
457
|
+
})),
|
|
458
|
+
empty: 'No health checks ran.'
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return reply.status(404).send({error: 'Not found'});
|
|
377
462
|
});
|
|
378
463
|
}
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import {authenticate as defaultAuthenticate, checkVisibility} from '../../middleware/auth.js';
|
|
18
18
|
import {getRegisteredSidebarItems} from '../../services/hooks.js';
|
|
19
19
|
import {filterItemsForUser, getMenu} from '../../services/menus.js';
|
|
20
|
+
import {isDashboardPro} from '../../services/widgets.js';
|
|
21
|
+
import {boardName, readPrefs, readSite} from '../../services/dashboardSettings.js';
|
|
20
22
|
|
|
21
23
|
export const ADMIN_SIDEBAR_SLUG = 'admin-sidebar';
|
|
22
24
|
|
|
@@ -35,7 +37,14 @@ export async function sidebarRoutes(fastify, opts = {}) {
|
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
fastify.get('/sidebar/registered-items', signedIn, async (request) => {
|
|
40
|
+
// Dashboard Pro: My Dashboard wears the name this person (or the site) gave it.
|
|
41
|
+
const boardLabel = isDashboardPro()
|
|
42
|
+
? boardName(await readPrefs(request.user), await readSite())
|
|
43
|
+
: null;
|
|
38
44
|
return getRegisteredSidebarItems()
|
|
45
|
+
.map(entry => (boardLabel && entry?.item?.url === '#/my-dashboard'
|
|
46
|
+
? {...entry, item: {...entry.item, text: boardLabel}}
|
|
47
|
+
: entry))
|
|
39
48
|
.filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
|
|
40
49
|
.map(entry => Array.isArray(entry.item?.items)
|
|
41
50
|
? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Pro - the widget list and the board layouts.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/dashboard/pro {enabled} - whether this site has the board (any signed-in user)
|
|
5
|
+
* GET /api/widgets the widgets this user may add (Pro)
|
|
6
|
+
* GET /api/dashboard/boards {boards: [{id, name, widgets}], active, source} (Pro)
|
|
7
|
+
* PUT /api/dashboard/boards save all of mine (Pro)
|
|
8
|
+
* GET /api/dashboard/layout the active board's layout {layout, source: mine|site|built-in} (Pro)
|
|
9
|
+
* PUT /api/dashboard/layout save mine (Pro)
|
|
10
|
+
* DELETE /api/dashboard/layout go back to the site's default (Pro)
|
|
11
|
+
* GET /api/dashboard/layout/default the site default (Pro, admins)
|
|
12
|
+
* PUT /api/dashboard/layout/default set it (Pro, admins)
|
|
13
|
+
* GET /api/dashboard/settings {mine, site, name, isAdmin, locked} - the cog (Pro)
|
|
14
|
+
* PUT /api/dashboard/settings save mine (Pro)
|
|
15
|
+
* PUT /api/dashboard/settings/site the name for everyone, and the layout lock (Pro, admins)
|
|
16
|
+
*
|
|
17
|
+
* A LOCKED site (settings/site `locked`) holds everyone but administrators to
|
|
18
|
+
* the site default: their GET answers it with source "locked", and their
|
|
19
|
+
* PUT / DELETE answer 423.
|
|
20
|
+
* Every route but /dashboard/pro answers 404 while no Pro Tool has switched the
|
|
21
|
+
* board on (services/widgets.js) - a free site has none of this.
|
|
22
|
+
*
|
|
23
|
+
* A layout is {version, widgets: [{id, size}]}, in board order. Mine is kept in
|
|
24
|
+
* content/dashboard/layouts/<user id>.json (ignored in a site repo - it is per
|
|
25
|
+
* user), the site default in content/dashboard/default.json (tracked).
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'fs/promises';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
|
|
30
|
+
import {getPermissionsForRoles} from '../../services/roles.js';
|
|
31
|
+
import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
|
|
32
|
+
import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_LAYOUT, activeBoard, boardsProblems, getWidgets, isDashboardPro, layoutProblems, normaliseBoards,
|
|
35
|
+
normaliseLayout, widgetsFor
|
|
36
|
+
} from '../../services/widgets.js';
|
|
37
|
+
import {boardName, prefsProblems, readPrefs, readSite, siteProblems, writePrefs, writeSite} from '../../services/dashboardSettings.js';
|
|
38
|
+
|
|
39
|
+
const DIR = path.resolve('content', 'dashboard');
|
|
40
|
+
const LAYOUTS = path.join(DIR, 'layouts');
|
|
41
|
+
const DEFAULT_FILE = path.join(DIR, 'default.json');
|
|
42
|
+
const USER_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
|
|
43
|
+
|
|
44
|
+
async function readJson(file) {
|
|
45
|
+
try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write via a temporary file, so a crash never leaves half a layout. */
|
|
49
|
+
async function writeJson(file, data) {
|
|
50
|
+
await fs.mkdir(path.dirname(file), {recursive: true});
|
|
51
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
52
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
53
|
+
await fs.rename(tmp, file);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const userFile = (user) => (USER_ID_RE.test(String(user?.id || '')) ? path.join(LAYOUTS, `${user.id}.json`) : null);
|
|
57
|
+
|
|
58
|
+
/** The site default, or the built-in one. */
|
|
59
|
+
async function siteDefault() {
|
|
60
|
+
const stored = await readJson(DEFAULT_FILE);
|
|
61
|
+
return stored ? {layout: normaliseLayout(stored), source: 'site'} : {layout: normaliseLayout(DEFAULT_LAYOUT), source: 'built-in'};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Who is asking, in the terms widgetsFor() takes. */
|
|
65
|
+
function who(user) {
|
|
66
|
+
const permissions = getPermissionsForRoles(getEffectiveRoles(user), getEffectiveRegistry().map(r => r.key));
|
|
67
|
+
return {permissions, isAdmin: getEffectiveLevel(user) <= 1};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function widgetRoutes(fastify, opts = {}) {
|
|
71
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
72
|
+
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
73
|
+
const pro = async (request, reply) => {
|
|
74
|
+
if (!isDashboardPro()) return reply.status(404).send({error: 'Not found'});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// isAdmin: whether this user may set the site's default layout.
|
|
78
|
+
fastify.get('/dashboard/pro', {preHandler: [authenticate]}, async (request) => ({
|
|
79
|
+
enabled: isDashboardPro(),
|
|
80
|
+
isAdmin: getEffectiveLevel(request.user) <= 1
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
fastify.get('/widgets', {preHandler: [authenticate, pro]}, async (request) => widgetsFor(getWidgets(), who(request.user)));
|
|
84
|
+
|
|
85
|
+
/** Whether this user is held to the site default (the lock, and not an admin). */
|
|
86
|
+
const heldToDefault = async (user) => !who(user).isAdmin && (await readSite()).locked;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* This person's boards, and where they came from: their own (mine), or -
|
|
90
|
+
* before they have arranged anything - one board from the site default
|
|
91
|
+
* (site / built-in). A locked site gives a non-admin that one board only.
|
|
92
|
+
*/
|
|
93
|
+
async function boardsFor(user) {
|
|
94
|
+
if (await heldToDefault(user)) {
|
|
95
|
+
const d = await siteDefault();
|
|
96
|
+
return {...normaliseBoards({boards: [{id: 'main', name: 'Main', widgets: d.layout.widgets}]}), source: 'locked'};
|
|
97
|
+
}
|
|
98
|
+
const file = userFile(user);
|
|
99
|
+
const mine = file ? await readJson(file) : null;
|
|
100
|
+
if (mine) return {...normaliseBoards(mine), source: 'mine'};
|
|
101
|
+
const d = await siteDefault();
|
|
102
|
+
return {...normaliseBoards({boards: [{id: 'main', name: 'Main', widgets: d.layout.widgets}]}), source: d.source};
|
|
103
|
+
}
|
|
104
|
+
const locked423 = (reply) => reply.status(423).send({error: 'Your administrator has locked the Dashboard layout.'});
|
|
105
|
+
|
|
106
|
+
fastify.get('/dashboard/boards', {preHandler: [authenticate, pro]}, async (request) => boardsFor(request.user));
|
|
107
|
+
|
|
108
|
+
fastify.put('/dashboard/boards', {preHandler: [authenticate, pro]}, async (request, reply) => {
|
|
109
|
+
if (await heldToDefault(request.user)) return locked423(reply);
|
|
110
|
+
const problems = boardsProblems(request.body);
|
|
111
|
+
if (problems.length) return reply.status(400).send({error: problems[0]});
|
|
112
|
+
const file = userFile(request.user);
|
|
113
|
+
if (!file) return reply.status(400).send({error: 'Your account cannot keep a layout.'});
|
|
114
|
+
const set = normaliseBoards(request.body);
|
|
115
|
+
await writeJson(file, set);
|
|
116
|
+
return {...set, source: 'mine'};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// The single-layout routes work on the ACTIVE board (what the board drew
|
|
120
|
+
// before there were several, and what a script may still call).
|
|
121
|
+
fastify.get('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request) => {
|
|
122
|
+
const set = await boardsFor(request.user);
|
|
123
|
+
return {layout: {version: 1, widgets: activeBoard(set).widgets}, source: set.source};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
fastify.put('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request, reply) => {
|
|
127
|
+
if (await heldToDefault(request.user)) return locked423(reply);
|
|
128
|
+
const problems = layoutProblems(request.body);
|
|
129
|
+
if (problems.length) return reply.status(400).send({error: problems[0]});
|
|
130
|
+
const file = userFile(request.user);
|
|
131
|
+
if (!file) return reply.status(400).send({error: 'Your account cannot keep a layout.'});
|
|
132
|
+
const set = await boardsFor(request.user);
|
|
133
|
+
const layout = normaliseLayout(request.body);
|
|
134
|
+
activeBoard(set).widgets = layout.widgets;
|
|
135
|
+
await writeJson(file, normaliseBoards(set));
|
|
136
|
+
return {layout, source: 'mine'};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Reset: every board goes, and the person starts again from the site default.
|
|
140
|
+
fastify.delete('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request, reply) => {
|
|
141
|
+
if (await heldToDefault(request.user)) return locked423(reply);
|
|
142
|
+
const file = userFile(request.user);
|
|
143
|
+
if (file) await fs.rm(file, {force: true});
|
|
144
|
+
return siteDefault();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
fastify.get('/dashboard/layout/default', {preHandler: [authenticate, pro, requireAdmin]}, async () => siteDefault());
|
|
148
|
+
|
|
149
|
+
fastify.put('/dashboard/layout/default', {preHandler: [authenticate, pro, requireAdmin]}, async (request, reply) => {
|
|
150
|
+
const problems = layoutProblems(request.body);
|
|
151
|
+
if (problems.length) return reply.status(400).send({error: problems[0]});
|
|
152
|
+
const layout = normaliseLayout(request.body);
|
|
153
|
+
await writeJson(DEFAULT_FILE, layout);
|
|
154
|
+
return {layout, source: 'site'};
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---- The cog ----
|
|
158
|
+
fastify.get('/dashboard/settings', {preHandler: [authenticate, pro]}, async (request) => {
|
|
159
|
+
const [mine, site] = await Promise.all([readPrefs(request.user), readSite()]);
|
|
160
|
+
const isAdmin = who(request.user).isAdmin;
|
|
161
|
+
return {mine, site, name: boardName(mine, site), isAdmin, locked: site.locked && !isAdmin};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
fastify.put('/dashboard/settings', {preHandler: [authenticate, pro]}, async (request, reply) => {
|
|
165
|
+
const problems = prefsProblems(request.body);
|
|
166
|
+
if (problems.length) return reply.status(400).send({error: problems[0]});
|
|
167
|
+
try {
|
|
168
|
+
const mine = await writePrefs(request.user, request.body);
|
|
169
|
+
const site = await readSite();
|
|
170
|
+
return {mine, site, name: boardName(mine, site)};
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return reply.status(400).send({error: err.message});
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
fastify.put('/dashboard/settings/site', {preHandler: [authenticate, pro, requireAdmin]}, async (request, reply) => {
|
|
177
|
+
const problems = siteProblems(request.body);
|
|
178
|
+
if (problems.length) return reply.status(400).send({error: problems[0]});
|
|
179
|
+
return {site: await writeSite(request.body)};
|
|
180
|
+
});
|
|
181
|
+
}
|
package/server/server.js
CHANGED
|
@@ -523,6 +523,7 @@ const {effectsRoutes} = await import('./routes/api/effects.js');
|
|
|
523
523
|
const {searchRoutes} = await import('./routes/api/search.js');
|
|
524
524
|
const {notificationsRoutes} = await import('./routes/api/notifications.js');
|
|
525
525
|
const {dashboardRoutes} = await import('./routes/api/dashboard.js');
|
|
526
|
+
const {widgetRoutes} = await import('./routes/api/widgets.js');
|
|
526
527
|
const {cacheRoutes} = await import('./routes/api/cache.js');
|
|
527
528
|
const {scaffoldRoutes} = await import('./routes/api/scaffold.js');
|
|
528
529
|
|
|
@@ -558,6 +559,7 @@ await app.register(effectsRoutes, {prefix: '/api'});
|
|
|
558
559
|
await app.register(searchRoutes, {prefix: '/api'});
|
|
559
560
|
await app.register(notificationsRoutes, {prefix: '/api'});
|
|
560
561
|
await app.register(dashboardRoutes, {prefix: '/api'});
|
|
562
|
+
await app.register(widgetRoutes, {prefix: '/api'});
|
|
561
563
|
await app.register(cacheRoutes, {prefix: '/api'});
|
|
562
564
|
await app.register(scaffoldRoutes, {prefix: '/api'});
|
|
563
565
|
// Tools promoted out of plugins/ - their own prefixes, not /api/plugins/*.
|
|
@@ -668,6 +670,7 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Content
|
|
|
668
670
|
// sub-folders have no key in older menus; findFolder() falls back to their name.
|
|
669
671
|
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Collections & Forms', url: '#/docs/usage/collections', icon: 'database'}});
|
|
670
672
|
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Built-in Tools', url: '#/docs/usage/tools', icon: 'tool'}});
|
|
673
|
+
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Dashboard', url: '#/docs/usage/dashboard', icon: 'home'}});
|
|
671
674
|
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions & Licences', url: '#/docs/usage/editions', icon: 'key'}});
|
|
672
675
|
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Forms', url: '#/docs/api/forms', icon: 'layout'}});
|
|
673
676
|
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Scaffold', url: '#/docs/api/scaffold', icon: 'package'}});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Pro settings - the board's cog (admin/js/views/dashboard-board.js).
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
*
|
|
6
|
+
* mine - each person's own: what their board is called, a greeting, how many
|
|
7
|
+
* columns and how dense, how often widgets update, and whether the
|
|
8
|
+
* admin opens on their board. content/dashboard/prefs/<user id>.json -
|
|
9
|
+
* per user, so ignored in a site repo like the layouts beside it.
|
|
10
|
+
* site - an administrator's, for everyone: the board's default name and a
|
|
11
|
+
* lock that holds everyone (but administrators) to the default
|
|
12
|
+
* layout. content/dashboard/site.json, tracked.
|
|
13
|
+
*
|
|
14
|
+
* Pure normalisers plus small file helpers; the routes are in
|
|
15
|
+
* routes/api/widgets.js. Tested in tests/services/dashboard-settings.test.js.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'fs/promises';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
|
|
20
|
+
export const COLUMNS = [3, 4, 6];
|
|
21
|
+
export const DENSITIES = ['normal', 'compact'];
|
|
22
|
+
/** live = each widget's own interval; slower = three times as long; paused = only by hand. */
|
|
23
|
+
export const UPDATES = ['live', 'slower', 'paused'];
|
|
24
|
+
export const NAME_MAX = 40;
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_PREFS = {name: '', greeting: true, columns: 4, density: 'normal', updates: 'live', home: false};
|
|
27
|
+
export const DEFAULT_SITE = {name: 'My Dashboard', locked: false};
|
|
28
|
+
|
|
29
|
+
const cleanName = (v) => String(v ?? '').replace(/[\u0000-\u001f<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, NAME_MAX);
|
|
30
|
+
|
|
31
|
+
/** A person's settings, cleaned: unknown values fall back to the defaults. */
|
|
32
|
+
export function normalisePrefs(input) {
|
|
33
|
+
const p = input && typeof input === 'object' ? input : {};
|
|
34
|
+
return {
|
|
35
|
+
name: cleanName(p.name),
|
|
36
|
+
greeting: p.greeting !== false,
|
|
37
|
+
columns: COLUMNS.includes(Number(p.columns)) ? Number(p.columns) : DEFAULT_PREFS.columns,
|
|
38
|
+
density: DENSITIES.includes(p.density) ? p.density : DEFAULT_PREFS.density,
|
|
39
|
+
updates: UPDATES.includes(p.updates) ? p.updates : DEFAULT_PREFS.updates,
|
|
40
|
+
home: p.home === true
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The site's settings, cleaned. A blank name falls back to "My Dashboard". */
|
|
45
|
+
export function normaliseSite(input) {
|
|
46
|
+
const s = input && typeof input === 'object' ? input : {};
|
|
47
|
+
return {name: cleanName(s.name) || DEFAULT_SITE.name, locked: s.locked === true};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What is wrong with submitted settings, as messages ([] when they are fine). */
|
|
51
|
+
export function prefsProblems(input) {
|
|
52
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return ['Settings must be an object.'];
|
|
53
|
+
const out = [];
|
|
54
|
+
if (input.name != null && String(input.name).length > NAME_MAX) out.push(`The name can be at most ${NAME_MAX} characters.`);
|
|
55
|
+
if (input.columns != null && !COLUMNS.includes(Number(input.columns))) out.push(`Columns must be one of ${COLUMNS.join(', ')}.`);
|
|
56
|
+
if (input.density != null && !DENSITIES.includes(input.density)) out.push(`Density must be ${DENSITIES.join(' or ')}.`);
|
|
57
|
+
if (input.updates != null && !UPDATES.includes(input.updates)) out.push(`Updates must be one of ${UPDATES.join(', ')}.`);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function siteProblems(input) {
|
|
62
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return ['Settings must be an object.'];
|
|
63
|
+
if (input.name != null && String(input.name).length > NAME_MAX) return [`The name can be at most ${NAME_MAX} characters.`];
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What the board is called for this person: their own name, else the site's. */
|
|
68
|
+
export function boardName(prefs, site) {
|
|
69
|
+
return cleanName(prefs?.name) || normaliseSite(site).name;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- Files -------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const DIR = () => path.resolve('content', 'dashboard');
|
|
75
|
+
const USER_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
|
|
76
|
+
|
|
77
|
+
async function readJson(file) {
|
|
78
|
+
try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return null; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function writeJson(file, data) {
|
|
82
|
+
await fs.mkdir(path.dirname(file), {recursive: true});
|
|
83
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
84
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
85
|
+
await fs.rename(tmp, file);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const prefsFile = (user) => (USER_ID_RE.test(String(user?.id || '')) ? path.join(DIR(), 'prefs', `${user.id}.json`) : null);
|
|
89
|
+
|
|
90
|
+
export async function readPrefs(user) {
|
|
91
|
+
const file = prefsFile(user);
|
|
92
|
+
return normalisePrefs(file ? await readJson(file) : null);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function writePrefs(user, input) {
|
|
96
|
+
const file = prefsFile(user);
|
|
97
|
+
if (!file) throw new Error('This account cannot keep settings.');
|
|
98
|
+
const prefs = normalisePrefs(input);
|
|
99
|
+
await writeJson(file, prefs);
|
|
100
|
+
return prefs;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function readSite() {
|
|
104
|
+
return normaliseSite(await readJson(path.join(DIR(), 'site.json')));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function writeSite(input) {
|
|
108
|
+
const site = normaliseSite(input);
|
|
109
|
+
await writeJson(path.join(DIR(), 'site.json'), site);
|
|
110
|
+
return site;
|
|
111
|
+
}
|
package/server/services/docs.js
CHANGED
|
@@ -58,6 +58,7 @@ const MAP = [
|
|
|
58
58
|
['usage-editions', 'usage/editions', 'Editions & Licences'],
|
|
59
59
|
['usage-collections', 'usage/collections', 'Collections & Forms'],
|
|
60
60
|
['usage-tools', 'usage/tools', 'Built-in Tools'],
|
|
61
|
+
['usage-dashboard', 'usage/dashboard', 'Dashboard'],
|
|
61
62
|
['tutorial-crud', 'tutorials/crud', 'Building a CRUD App'],
|
|
62
63
|
['tutorial-plugin', 'tutorials/plugin', 'Writing a Plugin'],
|
|
63
64
|
['tutorial-forms', 'tutorials/forms', 'Form Follow-Up'],
|
|
@@ -38,6 +38,7 @@ import {registerThemeProvider} from './themeSettings.js';
|
|
|
38
38
|
import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
|
|
39
39
|
import {notifierFor} from './notify.js';
|
|
40
40
|
import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
|
|
41
|
+
import {disableDashboardPro, enableDashboardPro, registerWidget, unregisterWidgetsFrom} from './widgets.js';
|
|
41
42
|
import {createCollection, getCollection} from './collections.js';
|
|
42
43
|
import * as defaultRolesService from './roles.js';
|
|
43
44
|
import {getAdminScope, normaliseAdminScope} from './roles.js';
|
|
@@ -560,6 +561,10 @@ export async function registerPlugins(fastify) {
|
|
|
560
561
|
// them on at boot. Bound to the plugin's name, tier and closedSource: only a paid plugin that
|
|
561
562
|
// loads under a licence may.
|
|
562
563
|
enableInstantCalls: () => enableInstantCalls({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
|
|
564
|
+
// Dashboard Pro (services/widgets.js) - the widget board; the same contract as instant calls.
|
|
565
|
+
enableDashboardPro: () => enableDashboardPro({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
|
|
566
|
+
// A widget from code (anything richer than a manifest's admin.widgets entry), owned by this plugin.
|
|
567
|
+
registerWidget: (def) => registerWidget({...def, owner: manifest.name, source: manifest.displayName || manifest.name}),
|
|
563
568
|
// Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
|
|
564
569
|
registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
|
|
565
570
|
// Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
|
|
@@ -594,6 +599,14 @@ export async function registerPlugins(fastify) {
|
|
|
594
599
|
}
|
|
595
600
|
}
|
|
596
601
|
|
|
602
|
+
// Dashboard widgets the manifest offers (services/widgets.js) - drawn by the admin
|
|
603
|
+
// from their `kind` and `dataUrl`, so a Tool needs no code to have one.
|
|
604
|
+
for (const def of manifest.admin?.widgets || []) {
|
|
605
|
+
if (!registerWidget({...def, owner: manifest.name, source: manifest.displayName || manifest.name})) {
|
|
606
|
+
fastify.log.warn(`[plugins] widget "${def?.id}" for "${manifest.name}" skipped: not a valid widget`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
597
610
|
// Ensure plugin collections exist (idempotent - skips if already present)
|
|
598
611
|
await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
|
|
599
612
|
.catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
|
|
@@ -1123,6 +1136,8 @@ export async function runLifecycleHook(name, hook, fastify) {
|
|
|
1123
1136
|
// A Tool with calls switched off stops keeping instant calls on - at once, not at the next restart.
|
|
1124
1137
|
if (hook === 'onDisable') {
|
|
1125
1138
|
disableInstantCalls(name);
|
|
1139
|
+
disableDashboardPro(name);
|
|
1140
|
+
unregisterWidgetsFrom(name);
|
|
1126
1141
|
unregisterContactAccess(name);
|
|
1127
1142
|
unregisterDirectMessenger(name);
|
|
1128
1143
|
}
|
|
@@ -317,6 +317,9 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
|
|
|
317
317
|
'/content/.migrations.json',
|
|
318
318
|
'/content/instances_log.json',
|
|
319
319
|
'/content/preview-links.json',
|
|
320
|
+
// Each admin's own Dashboard Pro layout (keyed by user id); the site default beside it is tracked.
|
|
321
|
+
'/content/dashboard/layouts/',
|
|
322
|
+
'/content/dashboard/prefs/',
|
|
320
323
|
'/config/connections.json*',
|
|
321
324
|
'/config/keys/',
|
|
322
325
|
'/plugins/*/data/*.jsonl',
|