domma-cms 0.75.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.
Files changed (150) hide show
  1. package/CLAUDE.md +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/action-editor-arrange.js +1 -0
  5. package/admin/js/lib/api-endpoint-editor-arrange.js +2 -0
  6. package/admin/js/lib/api-tokens-arrange.js +3 -0
  7. package/admin/js/lib/block-editor-arrange.js +2 -0
  8. package/admin/js/lib/collection-editor-arrange.js +3 -0
  9. package/admin/js/lib/collection-entries-arrange.js +1 -0
  10. package/admin/js/lib/component-editor-arrange.js +27 -0
  11. package/admin/js/lib/context-menu-editor-arrange.js +1 -0
  12. package/admin/js/lib/dashboard-arrange.js +1 -0
  13. package/admin/js/lib/effects-arrange.js +3 -0
  14. package/admin/js/lib/form-editor-arrange.js +1 -0
  15. package/admin/js/lib/form-submissions-arrange.js +2 -0
  16. package/admin/js/lib/layouts-arrange.js +1 -0
  17. package/admin/js/lib/menu-editor-arrange.js +2 -0
  18. package/admin/js/lib/my-profile-arrange.js +1 -0
  19. package/admin/js/lib/notifications-arrange.js +1 -0
  20. package/admin/js/lib/page-editor-arrange.js +1 -0
  21. package/admin/js/lib/plugin-code-arrange.js +1 -0
  22. package/admin/js/lib/plugins-arrange.js +2 -0
  23. package/admin/js/lib/project-page-arrange.js +1 -0
  24. package/admin/js/lib/role-editor-arrange.js +3 -0
  25. package/admin/js/lib/roles-arrange.js +1 -0
  26. package/admin/js/lib/search-settings-arrange.js +1 -0
  27. package/admin/js/lib/sidebar-renderer.js +2 -2
  28. package/admin/js/lib/site-settings-arrange.js +1 -0
  29. package/admin/js/lib/user-editor-arrange.js +1 -0
  30. package/admin/js/lib/users-arrange.js +1 -0
  31. package/admin/js/lib/view-editor-arrange.js +14 -0
  32. package/admin/js/templates/action-editor.html +223 -147
  33. package/admin/js/templates/api-endpoint-editor.html +172 -117
  34. package/admin/js/templates/api-tokens.html +83 -10
  35. package/admin/js/templates/block-editor.html +194 -203
  36. package/admin/js/templates/collection-editor.html +191 -113
  37. package/admin/js/templates/collection-entries.html +99 -36
  38. package/admin/js/templates/component-editor.html +128 -126
  39. package/admin/js/templates/context-menu-editor.html +208 -148
  40. package/admin/js/templates/dashboard.html +184 -22
  41. package/admin/js/templates/effects.html +155 -72
  42. package/admin/js/templates/form-editor.html +41 -26
  43. package/admin/js/templates/form-submissions.html +78 -26
  44. package/admin/js/templates/layouts.html +126 -43
  45. package/admin/js/templates/menu-editor.html +57 -17
  46. package/admin/js/templates/menu-locations.html +54 -11
  47. package/admin/js/templates/my-profile.html +100 -12
  48. package/admin/js/templates/notifications.html +70 -10
  49. package/admin/js/templates/page-editor.html +47 -26
  50. package/admin/js/templates/plugin-code.html +91 -73
  51. package/admin/js/templates/plugins.html +100 -28
  52. package/admin/js/templates/project-detail.html +83 -145
  53. package/admin/js/templates/project-settings.html +88 -53
  54. package/admin/js/templates/role-editor.html +158 -83
  55. package/admin/js/templates/roles.html +78 -7
  56. package/admin/js/templates/search.html +193 -216
  57. package/admin/js/templates/settings.html +404 -472
  58. package/admin/js/templates/user-editor.html +150 -14
  59. package/admin/js/templates/users.html +69 -9
  60. package/admin/js/templates/view-editor.html +203 -180
  61. package/admin/js/templates/view-preview.html +45 -45
  62. package/admin/js/views/action-editor.js +7 -1
  63. package/admin/js/views/api-endpoint-editor.js +2 -3
  64. package/admin/js/views/api-tokens.js +62 -8
  65. package/admin/js/views/block-editor.js +14 -8
  66. package/admin/js/views/collection-editor.js +64 -5
  67. package/admin/js/views/collection-entries.js +50 -1
  68. package/admin/js/views/component-editor.js +2 -28
  69. package/admin/js/views/context-menu-editor.js +54 -55
  70. package/admin/js/views/dashboard.js +7 -1
  71. package/admin/js/views/effects.js +31 -1
  72. package/admin/js/views/form-editor.js +7 -7
  73. package/admin/js/views/form-submissions.js +11 -1
  74. package/admin/js/views/index.js +1 -1
  75. package/admin/js/views/layouts.js +81 -1
  76. package/admin/js/views/login.js +2 -2
  77. package/admin/js/views/menu-editor.js +21 -43
  78. package/admin/js/views/menu-locations.js +16 -1
  79. package/admin/js/views/my-profile.js +1 -1
  80. package/admin/js/views/notifications.js +18 -1
  81. package/admin/js/views/page-editor.js +26 -26
  82. package/admin/js/views/plugin-code.js +23 -4
  83. package/admin/js/views/plugins.js +97 -39
  84. package/admin/js/views/project-detail.js +9 -1
  85. package/admin/js/views/project-settings.js +1 -1
  86. package/admin/js/views/role-editor.js +11 -3
  87. package/admin/js/views/roles.js +49 -4
  88. package/admin/js/views/search.js +7 -1
  89. package/admin/js/views/settings.js +13 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +13 -4
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/admin/js/views/view-preview.js +1 -1
  94. package/package.json +1 -1
  95. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  96. package/plugins/_lib/admin/mail/contacts.js +4 -1
  97. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  98. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  99. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  100. package/plugins/_lib/admin/mail/mail.css +1 -1
  101. package/plugins/_lib/admin/mail/mail.html +1 -1
  102. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  103. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  104. package/plugins/_lib/mail/readRoutes.js +43 -1
  105. package/plugins/_lib/mail/unreadBadge.js +42 -0
  106. package/plugins/free-tier.lock.json +5 -5
  107. package/plugins/mail-reader/CLAUDE.md +1 -0
  108. package/plugins/mail-reader/plugin.js +3 -1
  109. package/plugins/mail-reader/plugin.json +5 -4
  110. package/server/routes/api/api-tokens.js +56 -0
  111. package/server/routes/api/auth.js +7 -1
  112. package/server/routes/api/collections.js +13 -1
  113. package/server/routes/api/dashboard.js +32 -4
  114. package/server/routes/api/effects.js +68 -13
  115. package/server/routes/api/forms.js +104 -13
  116. package/server/routes/api/layouts.js +93 -2
  117. package/server/routes/api/notifications.js +140 -14
  118. package/server/routes/api/plugins.js +135 -4
  119. package/server/routes/api/roles.js +111 -0
  120. package/server/routes/api/search.js +38 -4
  121. package/server/routes/api/settings.js +52 -0
  122. package/server/routes/api/users.js +65 -3
  123. package/server/routes/api/views.js +55 -1
  124. package/server/server.js +12 -0
  125. package/server/services/components.js +9 -0
  126. package/server/services/plugins.js +7 -0
  127. package/server/services/roles.js +3 -0
  128. package/server/services/search.js +58 -0
  129. package/server/services/users.js +16 -2
  130. package/server/services/views.js +14 -0
  131. package/admin/js/templates/dashboard/activity-feed.html +0 -3
  132. package/admin/js/templates/dashboard/cache.html +0 -32
  133. package/admin/js/templates/dashboard/health-detail.html +0 -2
  134. package/admin/js/templates/dashboard/journeys.html +0 -17
  135. package/admin/js/templates/dashboard/kpi-strip.html +0 -34
  136. package/admin/js/templates/dashboard/spike-feed.html +0 -3
  137. package/admin/js/templates/dashboard/top-pages.html +0 -3
  138. package/admin/js/templates/dashboard/traffic-chart.html +0 -3
  139. package/admin/js/templates/plugin-marketplace.html +0 -29
  140. package/admin/js/views/block-editor-enhance.js +0 -1
  141. package/admin/js/views/dashboard/lib/escape.js +0 -1
  142. package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
  143. package/admin/js/views/dashboard/widgets/cache.js +0 -1
  144. package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
  145. package/admin/js/views/dashboard/widgets/journeys.js +0 -1
  146. package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
  147. package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
  148. package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
  149. package/admin/js/views/dashboard/widgets/traffic-chart.js +0 -1
  150. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -38,6 +38,7 @@ import {readPriority} from './priority.js';
38
38
  import {listAttachments, renderBody} from './render.js';
39
39
  import {readUnsubscribe} from './unsubscribe.js';
40
40
  import {MailKeyError} from './secretbox.js';
41
+ import {unreadBadge} from './unreadBadge.js';
41
42
 
42
43
  /**
43
44
  * Flatten an envelope address list into something the browser can render.
@@ -52,7 +53,7 @@ function addresses(list) {
52
53
  }));
53
54
  }
54
55
 
55
- export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginName, cachedList = null, diagnostics = null}) {
56
+ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginName, cachedList = null, diagnostics = null, unreadBadge: withUnreadBadge = false}) {
56
57
  const {authenticate} = auth;
57
58
 
58
59
  // A deleted user's stored mailbox passwords must not outlive their
@@ -165,6 +166,47 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
165
166
  // Accounts
166
167
  // -------------------------------------------------------------------------
167
168
 
169
+ /**
170
+ * GET /unread - the sidebar badge (the plugin.json countUrl). Opt-in:
171
+ * Email Pro counts from its store and registers its own. INBOX only, by
172
+ * STATUS (no select, so nothing can be marked read), and kept a minute
173
+ * per user - the sidebar asks every minute from every open admin tab.
174
+ */
175
+ if (withUnreadBadge) {
176
+ const badgeCache = new Map();
177
+ // A mailbox that failed (a changed password, a server down) is not asked
178
+ // again every minute: that is a failed IMAP login per minute per open tab,
179
+ // enough to trip fail2ban or a provider lockout. It backs off 5 min, doubling
180
+ // to an hour; saving the account (a new updatedAt) retries straight away.
181
+ const failures = new Map();
182
+ const failKey = (uid, account) => `${uid}:${account.id}:${account.updatedAt || account.createdAt || ''}`;
183
+ fastify.get('/unread', {preHandler: [authenticate]}, async (request, reply) => {
184
+ const uid = userId(request);
185
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
186
+ const hit = badgeCache.get(uid);
187
+ if (hit && Date.now() - hit.at < 60_000) return hit.value;
188
+ const boxes = [];
189
+ for (const account of await accounts.listAccounts(uid)) {
190
+ const key = failKey(uid, account);
191
+ const failed = failures.get(key);
192
+ if (failed && Date.now() < failed.until) { boxes.push({label: account.label, unseen: null}); continue; }
193
+ try {
194
+ const status = await pool.withClient(`${uid}:${account.id}`, accounts.toConnection(account),
195
+ client => client.status('INBOX', {unseen: true}));
196
+ failures.delete(key);
197
+ boxes.push({label: account.label, unseen: status?.unseen ?? 0});
198
+ } catch {
199
+ const wait = Math.min((failed?.wait || 150_000) * 2, 3_600_000);
200
+ failures.set(key, {wait, until: Date.now() + wait});
201
+ boxes.push({label: account.label, unseen: null});
202
+ }
203
+ }
204
+ const value = unreadBadge(boxes);
205
+ badgeCache.set(uid, {at: Date.now(), value});
206
+ return value;
207
+ });
208
+ }
209
+
168
210
  /** GET /accounts - every mailbox this user owns, without passwords. */
169
211
  fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
170
212
  const uid = userId(request);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The free reader's sidebar badge - unread mail in each mailbox's INBOX.
3
+ *
4
+ * Pure: the route asks IMAP (STATUS, which reads without selecting and so can
5
+ * never mark anything read) and hands the figures here. Email Pro has its own
6
+ * badge over the synchronised store; this is the edition without one, so it
7
+ * counts INBOX only - asking a server about every folder once a minute is
8
+ * how a mailbox gets rate-limited.
9
+ *
10
+ * Plain text throughout: the sidebar popover sets it with textContent.
11
+ *
12
+ * @module _lib/mail/unreadBadge
13
+ */
14
+
15
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
16
+
17
+ /**
18
+ * @param {{label?: string, unseen: number|null, error?: string}[]} boxes - one per mailbox
19
+ * @returns {{count: number, label: string, tone: string, details: object}}
20
+ */
21
+ export function unreadBadge(boxes = []) {
22
+ const known = boxes.filter(b => Number.isFinite(b.unseen));
23
+ const count = known.reduce((n, b) => n + b.unseen, 0);
24
+ const failed = boxes.length - known.length;
25
+ return {
26
+ count,
27
+ label: boxes.length
28
+ ? `${plural(count, 'unread message')} in the inbox${boxes.length > 1 ? 'es' : ''}${failed ? ` (${plural(failed, 'mailbox', 'mailboxes')} could not be checked)` : ''}`
29
+ : 'No mailbox set up',
30
+ tone: 'info',
31
+ details: {
32
+ title: 'Mail',
33
+ rows: boxes.map(b => ({
34
+ label: String(b.label || 'Mailbox'),
35
+ value: Number.isFinite(b.unseen) ? plural(b.unseen, 'unread', 'unread') : 'not reachable',
36
+ ...(Number.isFinite(b.unseen) ? {} : {tone: 'danger'})
37
+ })),
38
+ items: [],
39
+ empty: boxes.length ? 'Nothing unread in the inbox.' : 'Add a mailbox to see unread mail here.'
40
+ }
41
+ };
42
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@d9bfe49",
3
+ "source": "dcms-marketplace@5f64a68",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.6.3",
@@ -67,13 +67,13 @@
67
67
  }
68
68
  },
69
69
  "mail-reader": {
70
- "version": "1.0.0",
70
+ "version": "1.1.0",
71
71
  "files": {
72
- "CLAUDE.md": "a8dd2cb285e1d24b2f072aa11a644e1c8c1be89013da6f6cc46d9820fe24ccd5",
72
+ "CLAUDE.md": "7f317e69b560457f2d4c401b466754850afe75f6983bc41414c71d8c276c3583",
73
73
  "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
74
74
  "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
75
- "plugin.js": "79b17346ed0192b092040460cc8d762d5207ee6e4af7bfc2d1cf654a51e2fa81",
76
- "plugin.json": "5f5b98757514aa70d7c7a2be5a1fbfdde8ef21a2ccc7586b3b5aea9b44fbe5e6"
75
+ "plugin.js": "ebe4c41bdd8836fb3128af3a0e789d47f89b00e9e37dc9378bb308aaae8d41b2",
76
+ "plugin.json": "ed1f02d9405f6ae1257313ea4155ea49e307fb2210650fd247b92e6988aa78d1"
77
77
  }
78
78
  }
79
79
  }
@@ -24,6 +24,7 @@ All under `/api/plugins/mail-reader`, all requiring authentication.
24
24
 
25
25
  | Method | Path | Purpose |
26
26
  |---|---|---|
27
+ | GET | `/unread` | The sidebar badge: unread in each mailbox's INBOX, by STATUS (never selects, so nothing is marked read), cached a minute per user. `registerReadRoutes({unreadBadge: true})` - Email Pro counts from its store instead. |
27
28
  | GET | `/accounts` | Every mailbox this user owns. Never returns passwords, only `hasPassword`. |
28
29
  | POST | `/accounts` | Add a mailbox. |
29
30
  | PUT | `/accounts/:id` | Update one. A blank password means "keep the stored one". |
@@ -30,7 +30,9 @@ export default async function mailReaderPlugin(fastify, options) {
30
30
  hooks: options.hooks,
31
31
  config,
32
32
  pool,
33
- pluginName: 'mail-reader'
33
+ pluginName: 'mail-reader',
34
+ // The sidebar badge (plugin.json countUrl): unread in each INBOX.
35
+ unreadBadge: true
34
36
  });
35
37
  }
36
38
 
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mail-reader",
3
3
  "displayName": "Mail Reader",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "tier": "free",
6
6
  "description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
7
7
  "author": "Darryl Waterhouse",
8
- "date": "2026-09-19",
8
+ "date": "2026-09-25",
9
9
  "icon": "mail",
10
10
  "admin": {
11
11
  "sidebar": [
@@ -14,7 +14,8 @@
14
14
  "text": "Mail Reader",
15
15
  "icon": "mail",
16
16
  "url": "#/plugins/mail-reader",
17
- "section": "#/plugins/mail-reader"
17
+ "section": "#/plugins/mail-reader",
18
+ "countUrl": "/api/plugins/mail-reader/unread"
18
19
  }
19
20
  ],
20
21
  "routes": [
@@ -26,7 +27,7 @@
26
27
  ],
27
28
  "views": {
28
29
  "plugin-mail-reader": {
29
- "entry": "mail-reader/admin/views/mail.js?v=1",
30
+ "entry": "mail-reader/admin/views/mail.js?v=2",
30
31
  "exportName": "mailView"
31
32
  }
32
33
  }
@@ -2,6 +2,7 @@
2
2
  * API Tokens API
3
3
  *
4
4
  * GET /api/api-tokens - list tokens visible to the caller (sanitised; never the hash)
5
+ * GET /api/api-tokens/_count - the sidebar badge: tokens that need attention (expired / expiring)
5
6
  * POST /api/api-tokens - create; response carries the plaintext ONCE
6
7
  * PUT /api/api-tokens/:id - rename, enable/disable, edit scopes/expiry
7
8
  * DELETE /api/api-tokens/:id - revoke (immediate)
@@ -23,6 +24,58 @@ import {
23
24
  } from '../../services/apiTokens.js';
24
25
  import {canSeeArtefact} from '../../services/projects.js';
25
26
 
27
+ /** A token this close to its expiry is called out - SOON_DAYS in admin/js/lib/api-tokens-arrange.js. */
28
+ const SOON_DAYS = 14;
29
+ const DAY = 86_400_000;
30
+
31
+ /**
32
+ * The sidebar badge on API Tokens (its countUrl, GET /api/api-tokens/_count).
33
+ *
34
+ * The number is a signal, not an inventory: the tokens that need someone -
35
+ * switched on but already expired (every call with one is being refused, and
36
+ * nothing else says so), and switched on and expiring within SOON_DAYS. Zero is
37
+ * no badge. Red while any has expired. Plain text - the popover sets it with
38
+ * textContent; token names are the admin's own, never the secret.
39
+ *
40
+ * @param {object[]} tokens - sanitised, as listTokensSanitised returns them
41
+ * @param {number} [now]
42
+ */
43
+ export function apiTokensBadge(tokens = [], now = Date.now()) {
44
+ const on = tokens.filter(t => t.enabled !== false);
45
+ const at = (t) => (t.expiresAt ? Date.parse(t.expiresAt) : NaN);
46
+ const expired = on.filter(t => at(t) <= now);
47
+ const soon = on.filter(t => at(t) > now && at(t) - now <= SOON_DAYS * DAY)
48
+ .sort((a, b) => at(a) - at(b));
49
+ const off = tokens.length - on.length;
50
+ const need = expired.length + soon.length;
51
+ const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
52
+ const days = (t) => {
53
+ const d = Math.ceil((at(t) - now) / DAY);
54
+ return d <= 0 ? 'today' : d === 1 ? 'tomorrow' : `in ${d} days`;
55
+ };
56
+ return {
57
+ count: need,
58
+ label: need
59
+ ? [expired.length && `${plural(expired.length, 'token')} expired`, soon.length && `${soon.length} expiring soon`].filter(Boolean).join(', ')
60
+ : `${plural(tokens.length, 'token')}, none expiring soon`,
61
+ tone: expired.length ? 'danger' : 'attention',
62
+ details: {
63
+ title: 'API Tokens',
64
+ rows: [
65
+ {label: 'Working', value: String(on.length - expired.length)},
66
+ {label: 'Expiring soon', value: String(soon.length)},
67
+ {label: 'Expired', value: String(expired.length), ...(expired.length && {tone: 'danger'})},
68
+ {label: 'Switched off', value: String(off)}
69
+ ],
70
+ items: [
71
+ ...expired.map(t => ({text: String(t.name || '(unnamed)'), meta: `${t.project} · expired - callers are refused`, tone: 'danger'})),
72
+ ...soon.map(t => ({text: String(t.name || '(unnamed)'), meta: `${t.project} · expires ${days(t)}`}))
73
+ ].slice(0, 8),
74
+ empty: 'No token has expired or expires in the next two weeks.'
75
+ }
76
+ };
77
+ }
78
+
26
79
  /**
27
80
  * Register the api-tokens routes.
28
81
  *
@@ -43,6 +96,9 @@ export async function apiTokensRoutes(fastify, opts = {}) {
43
96
  return listTokensSanitised(request.user);
44
97
  });
45
98
 
99
+ // There is no GET /api-tokens/:id, and ids are uuids - nothing can shadow this.
100
+ fastify.get('/api-tokens/_count', canRead, async (request) => apiTokensBadge(await listTokensSanitised(request.user)));
101
+
46
102
  fastify.post('/api-tokens', canCreate, async (request, reply) => {
47
103
  const {name, project, scopes, expiresAt} = request.body || {};
48
104
  if (project && !canSeeArtefact(request.user, {meta: {project}})) {
@@ -170,7 +170,13 @@ export async function authRoutes(fastify) {
170
170
  if (email) coreUpdates.email = email;
171
171
  if (password) coreUpdates.password = password;
172
172
 
173
- const user = await updateUser(id, coreUpdates);
173
+ let user;
174
+ try {
175
+ user = await updateUser(id, coreUpdates);
176
+ } catch (err) {
177
+ if (err.code === 'EMAIL_TAKEN') return reply.status(409).send({error: err.message});
178
+ throw err;
179
+ }
174
180
 
175
181
  if (profile && typeof profile === 'object') {
176
182
  await updateProfile(id, profile);
@@ -54,7 +54,7 @@ import {
54
54
  updateEntry
55
55
  } from '../../services/collections.js';
56
56
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
57
- import {getRoleLevel, invalidate as invalidateRoles} from '../../services/roles.js';
57
+ import {getRoleLevel, invalidate as invalidateRoles, isBaseRole} from '../../services/roles.js';
58
58
  import {getEffectiveLevel} from '../../services/userRoles.js';
59
59
  import {getConfig, saveConfig} from '../../config.js';
60
60
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
@@ -262,6 +262,9 @@ async function keepRoleSystemFields(id, data) {
262
262
  if (existing[field] !== undefined) out[field] = existing[field];
263
263
  else delete out[field];
264
264
  }
265
+ // A built-in role keeps its name: renaming `admin` and then deleting it went round
266
+ // the delete guard (and every user and route that names it would lose it).
267
+ if (isBaseRole(existing.name)) out.name = existing.name;
265
268
  return out;
266
269
  }
267
270
 
@@ -494,6 +497,11 @@ export async function collectionsRoutes(fastify) {
494
497
  if (entry?.data?.level === 0) {
495
498
  return reply.status(403).send({ error: 'Cannot delete the root admin role' });
496
499
  }
500
+ // admin and user too: removeRole() refused them by name, but this route did not,
501
+ // and the old Roles screen offered Delete on both.
502
+ if (isBaseRole(entry?.data?.name)) {
503
+ return reply.status(403).send({ error: `Cannot delete the built-in role '${entry.data.name}'` });
504
+ }
497
505
  }
498
506
  try {
499
507
  await deleteEntry(request.params.slug, request.params.id);
@@ -507,6 +515,10 @@ export async function collectionsRoutes(fastify) {
507
515
 
508
516
  // Clear all entries - DELETE /collections/:slug/entries (no :id)
509
517
  fastify.delete('/collections/:slug/entries', canDelete, async (request, reply) => {
518
+ // Clearing roles would leave every user holding a role that no longer exists.
519
+ if (request.params.slug === 'roles') {
520
+ return reply.status(403).send({ error: 'Roles cannot be cleared - delete them one at a time' });
521
+ }
510
522
  try {
511
523
  await clearEntries(request.params.slug);
512
524
  return { success: true };
@@ -11,6 +11,7 @@ import {fileURLToPath} from 'url';
11
11
  import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
12
12
  import {getHealth} from '../../services/health.js';
13
13
  import {listVersions} from '../../services/versions.js';
14
+ import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
17
  const ROOT = path.resolve(__dirname, '..', '..', '..');
@@ -62,6 +63,24 @@ function sumRange(daily, fromKey, toKey) {
62
63
  return total;
63
64
  }
64
65
 
66
+ /**
67
+ * Daily totals for the last `days` days, oldest first - the traffic chart.
68
+ * (The chart used to add up the top five pages' 7-day sparklines and call it
69
+ * "the last 30 days": everything but the last week read 0.)
70
+ *
71
+ * @param {object} daily
72
+ * @param {number} [days]
73
+ * @returns {{day: string, hits: number}[]}
74
+ */
75
+ export function buildSeries(daily, days = 30) {
76
+ const out = [];
77
+ for (let i = days - 1; i >= 0; i -= 1) {
78
+ const day = isoDaysAgo(i);
79
+ out.push({day, hits: sumDay(daily, day)});
80
+ }
81
+ return out;
82
+ }
83
+
65
84
  /**
66
85
  * Build the top-pages widget data, including a per-day sparkline and a
67
86
  * percentage delta versus the previous comparable window.
@@ -212,12 +231,19 @@ async function buildActivity() {
212
231
  } catch { /* no versions dir */ }
213
232
 
214
233
  // Collection entries - each slug has a single data.json with an array of
215
- // entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
234
+ // entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt. The
235
+ // system's own collections (roles, users' profiles, API tokens, projects…)
236
+ // are not activity anyone asked to see - they were listed as "received
237
+ // submission" before. An entry is `type: 'entry'` with its collection.
238
+ const SYSTEM = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
216
239
  const collectionsDir = path.join(ROOT, 'content', 'collections');
217
240
  try {
218
241
  const slugs = await fs.readdir(collectionsDir, { withFileTypes: true });
219
242
  for (const slug of slugs) {
220
- if (!slug.isDirectory()) continue;
243
+ if (!slug.isDirectory() || SYSTEM.has(slug.name)) continue;
244
+ let title = slug.name;
245
+ try { title = JSON.parse(await fs.readFile(path.join(collectionsDir, slug.name, 'schema.json'), 'utf8')).title || title; }
246
+ catch { /* no schema - keep the slug */ }
221
247
  const dataFile = path.join(collectionsDir, slug.name, 'data.json');
222
248
  let entries = [];
223
249
  try { entries = JSON.parse(await fs.readFile(dataFile, 'utf8')); }
@@ -231,9 +257,10 @@ async function buildActivity() {
231
257
 
232
258
  for (const e of sorted) {
233
259
  items.push({
234
- type: 'form',
260
+ type: 'entry',
235
261
  at: e.meta.createdAt,
236
- form: slug.name
262
+ collection: slug.name,
263
+ title
237
264
  });
238
265
  }
239
266
  }
@@ -338,6 +365,7 @@ export async function dashboardRoutes(fastify, opts = {}) {
338
365
  }
339
366
 
340
367
  const topPages = buildTopPages(daily);
368
+ traffic.series = buildSeries(daily);
341
369
  const journeys = (await safe('analytics.journeys', () => {
342
370
  const end = new Date(); end.setUTCHours(0, 0, 0, 0);
343
371
  const start = new Date(end); start.setUTCDate(start.getUTCDate() - 6);
@@ -2,22 +2,77 @@
2
2
  * Effects API Routes
3
3
  * GET /api/effects/settings - public (read by public site IIFE)
4
4
  * PUT /api/effects/settings - admin-only (save user overrides)
5
+ *
6
+ * The PUT whitelists and checks what it stores (normaliseEffectsSettings): it
7
+ * used to write the request body to config/effects.json verbatim, and GET
8
+ * hands that file to every public page. Keys this route does not know are
9
+ * left as they are in the file, never added from a request.
5
10
  */
6
- import {authenticate, requireAdmin} from '../../middleware/auth.js';
11
+ import {
12
+ authenticate as defaultAuthenticate,
13
+ requireAdmin as defaultRequireAdmin
14
+ } from '../../middleware/auth.js';
7
15
  import {getConfig, saveConfig} from '../../config.js';
8
16
 
9
- export async function effectsRoutes(fastify) {
10
- fastify.get('/effects/settings', async () => {
11
- try {
12
- return getConfig('effects');
13
- } catch {
14
- return {respectMotion: true, defaultDuration: 600, defaultAnimation: 'fade', defaultThreshold: 0.1};
15
- }
16
- });
17
+ export const EFFECT_ANIMATIONS = ['fade', 'slide-up', 'slide-down', 'zoom', 'flip'];
18
+ export const EFFECTS_DEFAULTS = {respectMotion: true, defaultDuration: 600, defaultAnimation: 'fade', defaultThreshold: 0.1};
19
+
20
+ /**
21
+ * A request body -> {errors, settings}. The same rules as checkSettings() in
22
+ * admin/js/lib/effects-arrange.js. A duration of 0 is refused: public/js/effects.js
23
+ * reads `defaultDuration || 600`, so 0 would silently mean 600.
24
+ *
25
+ * @param {object} body
26
+ * @returns {{errors: string[], settings: object}}
27
+ */
28
+ export function normaliseEffectsSettings(body) {
29
+ const b = body && typeof body === 'object' ? body : {};
30
+ const errors = [];
31
+ const settings = {};
32
+ if ('respectMotion' in b) settings.respectMotion = b.respectMotion !== false;
33
+ if ('defaultDuration' in b) {
34
+ const n = Number(b.defaultDuration);
35
+ if (!Number.isInteger(n) || n < 1 || n > 10000) errors.push('defaultDuration must be a whole number from 1 to 10000');
36
+ else settings.defaultDuration = n;
37
+ }
38
+ if ('defaultAnimation' in b) {
39
+ if (!EFFECT_ANIMATIONS.includes(b.defaultAnimation)) errors.push(`defaultAnimation must be one of ${EFFECT_ANIMATIONS.join(', ')}`);
40
+ else settings.defaultAnimation = b.defaultAnimation;
41
+ }
42
+ if ('defaultThreshold' in b) {
43
+ const n = Number(b.defaultThreshold);
44
+ if (b.defaultThreshold === '' || b.defaultThreshold === null || !Number.isFinite(n) || n < 0 || n > 1) {
45
+ errors.push('defaultThreshold must be a number from 0 to 1');
46
+ } else settings.defaultThreshold = n;
47
+ }
48
+ return {errors, settings};
49
+ }
50
+
51
+ function loadSettings() {
52
+ try {
53
+ return {...EFFECTS_DEFAULTS, ...(getConfig('effects') || {})};
54
+ } catch {
55
+ return {...EFFECTS_DEFAULTS};
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {import('fastify').FastifyInstance} fastify
61
+ * @param {{authenticate?: Function, requireAdmin?: Function}} [opts] - injectable for tests
62
+ */
63
+ export async function effectsRoutes(fastify, opts = {}) {
64
+ const authenticate = opts.authenticate || defaultAuthenticate;
65
+ const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
66
+
67
+ fastify.get('/effects/settings', async () => loadSettings());
17
68
 
18
- fastify.put('/effects/settings', {preHandler: [authenticate, requireAdmin]}, async (request) => {
19
- const body = request.body || {};
20
- saveConfig('effects', body);
21
- return {ok: true};
69
+ fastify.put('/effects/settings', {preHandler: [authenticate, requireAdmin]}, async (request, reply) => {
70
+ const {errors, settings} = normaliseEffectsSettings(request.body);
71
+ if (errors.length) return reply.status(400).send({error: errors.join('; ')});
72
+ let current = {};
73
+ try { current = getConfig('effects') || {}; } catch { /* no file yet */ }
74
+ const next = {...current, ...settings};
75
+ saveConfig('effects', next);
76
+ return {ok: true, settings: {...EFFECTS_DEFAULTS, ...next}};
22
77
  });
23
78
  }