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.
- package/CLAUDE.md +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- 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/api-tokens-arrange.js +3 -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/effects-arrange.js +3 -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/layouts-arrange.js +1 -0
- package/admin/js/lib/menu-editor-arrange.js +2 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -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/plugins-arrange.js +2 -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/roles-arrange.js +1 -0
- package/admin/js/lib/search-settings-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/user-editor-arrange.js +1 -0
- package/admin/js/lib/users-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/api-tokens.html +83 -10
- 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/effects.html +155 -72
- package/admin/js/templates/form-editor.html +41 -26
- package/admin/js/templates/form-submissions.html +78 -26
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/menu-editor.html +57 -17
- package/admin/js/templates/menu-locations.html +54 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/page-editor.html +47 -26
- package/admin/js/templates/plugin-code.html +91 -73
- package/admin/js/templates/plugins.html +100 -28
- 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/roles.html +78 -7
- package/admin/js/templates/search.html +193 -216
- package/admin/js/templates/settings.html +404 -472
- package/admin/js/templates/user-editor.html +150 -14
- package/admin/js/templates/users.html +69 -9
- 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/api-tokens.js +62 -8
- 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/effects.js +31 -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/layouts.js +81 -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/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/plugin-code.js +23 -4
- package/admin/js/views/plugins.js +97 -39
- 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/roles.js +49 -4
- package/admin/js/views/search.js +7 -1
- package/admin/js/views/settings.js +13 -3
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/view-preview.js +1 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +97 -40
- package/plugins/_lib/admin/mail/contacts.js +4 -1
- package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
- package/plugins/_lib/admin/mail/folder-tree.js +1 -1
- package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/mail.html +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +303 -44
- package/plugins/_lib/admin/mail/when-closed.js +26 -0
- package/plugins/_lib/mail/readRoutes.js +43 -1
- package/plugins/_lib/mail/unreadBadge.js +42 -0
- package/plugins/free-tier.lock.json +5 -5
- package/plugins/mail-reader/CLAUDE.md +1 -0
- package/plugins/mail-reader/plugin.js +3 -1
- package/plugins/mail-reader/plugin.json +5 -4
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/collections.js +13 -1
- package/server/routes/api/dashboard.js +32 -4
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +104 -13
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/plugins.js +135 -4
- package/server/routes/api/roles.js +111 -0
- package/server/routes/api/search.js +38 -4
- package/server/routes/api/settings.js +52 -0
- package/server/routes/api/users.js +65 -3
- package/server/routes/api/views.js +55 -1
- package/server/server.js +12 -0
- package/server/services/components.js +9 -0
- package/server/services/plugins.js +7 -0
- package/server/services/roles.js +3 -0
- package/server/services/search.js +58 -0
- package/server/services/users.js +16 -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/templates/plugin-marketplace.html +0 -29
- 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
- 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@
|
|
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.
|
|
70
|
+
"version": "1.1.0",
|
|
71
71
|
"files": {
|
|
72
|
-
"CLAUDE.md": "
|
|
72
|
+
"CLAUDE.md": "7f317e69b560457f2d4c401b466754850afe75f6983bc41414c71d8c276c3583",
|
|
73
73
|
"admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
|
|
74
74
|
"config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
|
|
75
|
-
"plugin.js": "
|
|
76
|
-
"plugin.json": "
|
|
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.
|
|
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-
|
|
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=
|
|
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
|
-
|
|
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: '
|
|
260
|
+
type: 'entry',
|
|
235
261
|
at: e.meta.createdAt,
|
|
236
|
-
|
|
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 {
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
}
|