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.
@@ -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
+ }
@@ -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',