domma-cms 0.86.0 → 0.88.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 +9 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/http-interceptor.js +1 -1
- package/admin/js/lib/login-arrange.js +1 -0
- package/admin/js/lib/password-field.js +1 -0
- package/admin/js/lib/password-reset-actions.js +12 -0
- package/admin/js/lib/plugins-arrange.js +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -1
- package/admin/js/lib/users-arrange.js +1 -1
- package/admin/js/templates/login.html +33 -5
- package/admin/js/templates/plugins.html +7 -0
- package/admin/js/templates/settings.html +6 -0
- package/admin/js/templates/user-editor.html +7 -0
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/plugins.js +17 -7
- package/admin/js/views/settings.js +4 -4
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -2
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +4 -4
- package/plugins/mail-reader/CLAUDE.md +5 -0
- package/plugins/mail-reader/plugin.json +5 -2
- package/server/routes/api/auth.js +41 -63
- package/server/routes/api/plugins.js +33 -3
- package/server/routes/api/related.js +6 -3
- package/server/routes/api/settings.js +12 -0
- package/server/routes/api/todo.js +11 -0
- package/server/routes/api/tools.js +116 -0
- package/server/routes/api/users.js +49 -2
- package/server/server.js +27 -8
- package/server/services/notification-sources.js +82 -1
- package/server/services/passwordReset.js +507 -0
- package/server/services/plugins.js +27 -1
- package/server/services/renderer.js +5 -2
- package/server/services/tools.js +259 -0
- package/server/services/users.js +8 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* POST /api/settings/test-email - send a test email using stored SMTP config
|
|
6
6
|
* GET /api/settings/_count - the sidebar badge: what is wrong with the settings
|
|
7
7
|
*/
|
|
8
|
+
import {normaliseOrigin} from '../../services/passwordReset.js';
|
|
8
9
|
import {getConfig, saveConfig} from '../../config.js';
|
|
9
10
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
10
11
|
import {createTransport} from '../../services/email.js';
|
|
@@ -89,6 +90,8 @@ export async function settingsRoutes(fastify) {
|
|
|
89
90
|
'adminBrand',
|
|
90
91
|
// The screen `#/` (Dashboard) opens - see services/adminHome.js.
|
|
91
92
|
'adminHome',
|
|
93
|
+
// The site's own address - canonical URLs, the sitemap and password reset links.
|
|
94
|
+
'baseUrl',
|
|
92
95
|
// Keys that legitimately live in site.json (written elsewhere - e.g.
|
|
93
96
|
// `brand` by the menus migration) and are echoed back by GET /settings.
|
|
94
97
|
// They must be accepted here or any client that round-trips the GET
|
|
@@ -99,6 +102,15 @@ export async function settingsRoutes(fastify) {
|
|
|
99
102
|
if (unknownKeys.length > 0) {
|
|
100
103
|
return reply.status(400).send({ error: `Unknown settings keys: ${unknownKeys.join(', ')}` });
|
|
101
104
|
}
|
|
105
|
+
if ('baseUrl' in data) {
|
|
106
|
+
// An origin only (https://example.com): a reset link is built on it, so nothing else may pass.
|
|
107
|
+
const raw = data.baseUrl == null ? '' : String(data.baseUrl).trim();
|
|
108
|
+
const origin = raw ? normaliseOrigin(raw) : '';
|
|
109
|
+
if (raw && !origin) {
|
|
110
|
+
return reply.status(400).send({error: 'Site URL must be an address like https://example.com - no path.'});
|
|
111
|
+
}
|
|
112
|
+
data.baseUrl = origin;
|
|
113
|
+
}
|
|
102
114
|
if ('adminHome' in data) {
|
|
103
115
|
// An admin route, or blank for the built-in dashboard.
|
|
104
116
|
const home = data.adminHome == null || data.adminHome === '' ? '/' : normaliseAdminPath(data.adminHome);
|
|
@@ -87,6 +87,14 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
87
87
|
return request.user?.id ?? request.user?.sub ?? null;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** A to-do was completed, removed or re-dated: this person's notices follow at once (0.87). Never throws. */
|
|
91
|
+
async function tell(uid) {
|
|
92
|
+
try {
|
|
93
|
+
const {todoChanged} = await import('../../services/notification-sources.js');
|
|
94
|
+
await todoChanged(uid, await loadAll(uid), localDay(new Date()));
|
|
95
|
+
} catch { /* a notice never breaks a to-do */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
async function loadAll(uid) {
|
|
91
99
|
const {entries} = await listEntries(SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
|
|
92
100
|
let items = entries.map(toTodo);
|
|
@@ -185,6 +193,7 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
const updated = await updateEntry(SLUG, id, merged);
|
|
196
|
+
if ('status' in patch || 'dueAt' in patch) tell(entry.data.userId);
|
|
188
197
|
return reply.send(toTodo(updated));
|
|
189
198
|
});
|
|
190
199
|
|
|
@@ -194,6 +203,7 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
194
203
|
const all = await loadAll(uid);
|
|
195
204
|
const completed = all.filter(t => t.status === 'completed');
|
|
196
205
|
await Promise.all(completed.map(t => deleteEntry(SLUG, t.id)));
|
|
206
|
+
if (completed.length) tell(uid);
|
|
197
207
|
return reply.send({success: true, removed: completed.length});
|
|
198
208
|
});
|
|
199
209
|
|
|
@@ -207,6 +217,7 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
207
217
|
if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
|
|
208
218
|
|
|
209
219
|
await deleteEntry(SLUG, id);
|
|
220
|
+
tell(entry.data.userId);
|
|
210
221
|
return reply.send({ success: true });
|
|
211
222
|
});
|
|
212
223
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Tool switches - Contacts, Notes, Todo, Analytics (services/tools.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/tools - each built-in Tool, on/off, locked, and the plugins that depend on it (plugins.read)
|
|
5
|
+
* GET /api/tools/enabled - {name: boolean}, for any signed-in user (plugin screens hide what needs a Tool that is off)
|
|
6
|
+
* PUT /api/tools/:name - {enabled, cascade?} (plugins.update). Answers 423 when the site's manager has
|
|
7
|
+
* locked it, and 409 with the plan when the switch moves other Tools too - repeat
|
|
8
|
+
* with `cascade: true` to apply. Restarts the site like a plugin switch does.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
authenticate as defaultAuthenticate,
|
|
12
|
+
requirePermission as defaultRequirePermission
|
|
13
|
+
} from '../../middleware/auth.js';
|
|
14
|
+
import {discoverPlugins, getPluginStates, runLifecycleHook, savePluginState} from '../../services/plugins.js';
|
|
15
|
+
import {CORE_TOOLS, getToolStates, isCoreTool, isToolEnabled, planSwitch, saveToolState, switchableUnits} from '../../services/tools.js';
|
|
16
|
+
import {autoRestartEnabled, hasSupervisor, scheduleRestart} from './plugins.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Write the other switches a plan moved: a plugin's enabled flag (with its
|
|
20
|
+
* lifecycle hook, as a direct switch gets) or a built-in Tool's.
|
|
21
|
+
*
|
|
22
|
+
* @param {{name: string, enabled: boolean}[]} cascaded
|
|
23
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
24
|
+
*/
|
|
25
|
+
export async function applyCascade(cascaded, fastify) {
|
|
26
|
+
for (const {name, enabled} of cascaded) {
|
|
27
|
+
if (isCoreTool(name)) {
|
|
28
|
+
saveToolState(name, {enabled});
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
savePluginState(name, {enabled});
|
|
32
|
+
await runLifecycleHook(name, enabled ? 'onEnable' : 'onDisable', fastify);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A built-in Tool's routes behind its switch: 404 while it is off, read per
|
|
38
|
+
* request so the API stops the moment the switch is saved. Register it with
|
|
39
|
+
* the Tool's prefix; the inner routes must NOT be given it again, or every
|
|
40
|
+
* route lands at /api/notes/api/notes/... (0.88.0 did exactly that on dcm).
|
|
41
|
+
*
|
|
42
|
+
* @param {string} name the Tool (services/tools.js CORE_TOOLS)
|
|
43
|
+
* @param {Function} routes its Fastify plugin
|
|
44
|
+
* @param {(name: string) => boolean} [isEnabled]
|
|
45
|
+
*/
|
|
46
|
+
export function toolGate(name, routes, isEnabled = isToolEnabled) {
|
|
47
|
+
const displayName = CORE_TOOLS.find(t => t.name === name)?.displayName || name;
|
|
48
|
+
return async (scope) => {
|
|
49
|
+
scope.addHook('onRequest', async (request, reply) => {
|
|
50
|
+
if (!isEnabled(name)) return reply.code(404).send({error: `${displayName} is switched off on this site.`});
|
|
51
|
+
});
|
|
52
|
+
await scope.register(routes);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function toolsRoutes(fastify, opts = {}) {
|
|
57
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
58
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
59
|
+
const exit = opts.exit || (() => process.exit(0));
|
|
60
|
+
|
|
61
|
+
const canRead = {preHandler: [authenticate, requirePermission('plugins', 'read')]};
|
|
62
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('plugins', 'update')]};
|
|
63
|
+
|
|
64
|
+
const unitsNow = async () => switchableUnits({
|
|
65
|
+
manifests: await discoverPlugins(), pluginStates: getPluginStates(), toolStates: getToolStates()
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
fastify.get('/tools', canRead, async () => {
|
|
69
|
+
const units = await unitsNow();
|
|
70
|
+
const plugins = [...units.values()].filter(u => u.kind === 'plugin');
|
|
71
|
+
return CORE_TOOLS.map(t => {
|
|
72
|
+
const u = units.get(t.name);
|
|
73
|
+
return {
|
|
74
|
+
...t,
|
|
75
|
+
enabled: u.enabled,
|
|
76
|
+
locked: u.locked,
|
|
77
|
+
// Enabled plugins that stop, or lose something, if this goes off.
|
|
78
|
+
requiredBy: plugins.filter(p => p.enabled && p.requires.includes(t.name)).map(p => ({name: p.name, displayName: p.displayName})),
|
|
79
|
+
usedBy: plugins.filter(p => p.enabled && t.name in p.uses).map(p => ({name: p.name, displayName: p.displayName, reason: p.uses[t.name]}))
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
fastify.get('/tools/enabled', {preHandler: [authenticate]}, async () => {
|
|
85
|
+
const states = getToolStates();
|
|
86
|
+
return Object.fromEntries(CORE_TOOLS.map(t => [t.name, states[t.name]?.enabled !== false]));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
fastify.put('/tools/:name', canUpdate, async (request, reply) => {
|
|
90
|
+
const {name} = request.params;
|
|
91
|
+
if (!isCoreTool(name)) return reply.status(404).send({error: 'No such built-in Tool'});
|
|
92
|
+
const {enabled, cascade} = request.body || {};
|
|
93
|
+
if (typeof enabled !== 'boolean') return reply.status(400).send({error: 'enabled must be true or false'});
|
|
94
|
+
|
|
95
|
+
const plan = planSwitch(await unitsNow(), {[name]: enabled});
|
|
96
|
+
if (!plan.ok) {
|
|
97
|
+
const r = plan.refused[0];
|
|
98
|
+
const who = CORE_TOOLS.find(t => t.name === r.name)?.displayName || r.name;
|
|
99
|
+
return reply.status(r.reason.includes('locked') ? 423 : 409).send({error: `${who} ${r.reason}.`, plan});
|
|
100
|
+
}
|
|
101
|
+
if (!Object.keys(plan.changes).length) return {success: true};
|
|
102
|
+
if ((plan.cascaded.length || plan.losses.length) && cascade !== true) {
|
|
103
|
+
return reply.status(409).send({error: 'This switch changes other Tools too.', needsConfirm: true, plan});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
saveToolState(name, {enabled});
|
|
107
|
+
await applyCascade(plan.cascaded, fastify);
|
|
108
|
+
|
|
109
|
+
// Sidebar entries, notification and calendar sources are registered at
|
|
110
|
+
// boot, so the switch takes a restart - the same rule as a plugin's.
|
|
111
|
+
const supervised = hasSupervisor();
|
|
112
|
+
const restarting = supervised && autoRestartEnabled();
|
|
113
|
+
if (restarting) scheduleRestart(exit);
|
|
114
|
+
return {success: true, restartRequired: true, supervised, restarting, plan};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -6,16 +6,20 @@
|
|
|
6
6
|
* POST /api/users - create user (admin, manager - manager cannot create admin)
|
|
7
7
|
* PUT /api/users/:id - update user (admin, manager - manager cannot edit admin)
|
|
8
8
|
* DELETE /api/users/:id - delete user (admin, manager - manager cannot delete admin, no self-delete)
|
|
9
|
+
* POST /api/users/:id/password-reset - {via: 'email'|'link'} - email them a reset link, or return one to copy
|
|
10
|
+
* DELETE /api/users/:id/password-reset - withdraw an unused reset link
|
|
11
|
+
* Both: users.update and a more senior role than theirs (canManageUser); never your own account.
|
|
9
12
|
*/
|
|
10
13
|
import {authenticate, canManageUser, requirePermission} from '../../middleware/auth.js';
|
|
11
14
|
import {getPermissionsFor} from '../../services/roles.js';
|
|
12
|
-
import {createUser, deleteUser, getUserById, listUsers, updateUser} from '../../services/users.js';
|
|
15
|
+
import {clearResetToken, createUser, deleteUser, getUserById, listUsers, updateUser} from '../../services/users.js';
|
|
13
16
|
import {getProfile, listProfiles, updateProfile} from '../../services/userProfiles.js';
|
|
14
17
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
15
18
|
import * as notices from '../../services/notification-sources.js';
|
|
16
19
|
import {checkPassword} from '../../services/authChecks.js';
|
|
17
20
|
import {revokeUserSessions} from '../../services/sessions.js';
|
|
18
21
|
import {hooks} from '../../services/hooks.js';
|
|
22
|
+
import {adminOrigin, adminReset} from '../../services/passwordReset.js';
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* The sidebar badge on Users (its countUrl, GET /api/users/_count). The number
|
|
@@ -31,6 +35,7 @@ export function usersBadge(users = [], now = Date.now()) {
|
|
|
31
35
|
const list = users || [];
|
|
32
36
|
const inactive = list.filter(u => u.isActive === false);
|
|
33
37
|
const never = list.filter(u => !u.lastLogin);
|
|
38
|
+
const resets = list.filter(u => u.resetLinkExpires && Date.parse(u.resetLinkExpires) > now);
|
|
34
39
|
const week = list.filter(u => u.lastLogin && now - new Date(u.lastLogin).getTime() < 7 * 86_400_000);
|
|
35
40
|
const recent = list.filter(u => u.lastLogin)
|
|
36
41
|
.sort((a, b) => String(b.lastLogin).localeCompare(String(a.lastLogin)));
|
|
@@ -44,7 +49,8 @@ export function usersBadge(users = [], now = Date.now()) {
|
|
|
44
49
|
{label: 'Active', value: String(list.length - inactive.length)},
|
|
45
50
|
{label: 'Inactive', value: String(inactive.length)},
|
|
46
51
|
{label: 'Signed in this week', value: String(week.length)},
|
|
47
|
-
{label: 'Never signed in', value: String(never.length)}
|
|
52
|
+
{label: 'Never signed in', value: String(never.length)},
|
|
53
|
+
...(resets.length ? [{label: 'Reset links open', value: String(resets.length)}] : [])
|
|
48
54
|
],
|
|
49
55
|
items: recent.slice(0, 6).map(u => ({
|
|
50
56
|
text: clip(u.name || u.email || 'User'),
|
|
@@ -221,6 +227,47 @@ export async function usersRoutes(fastify) {
|
|
|
221
227
|
return {...user, profile: profileEntry?.data || {}};
|
|
222
228
|
});
|
|
223
229
|
|
|
230
|
+
// An administrator's password reset - email the user a link, or get one to pass on yourself
|
|
231
|
+
// (a site without email, a mailbox that is not receiving). Your own password is changed in My Profile.
|
|
232
|
+
const resetTarget = async (request, reply) => {
|
|
233
|
+
const {id} = request.params;
|
|
234
|
+
if (request.user.id === id) {
|
|
235
|
+
reply.code(403).send({error: 'Change your own password in My Profile.'});
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const target = await getUserById(id);
|
|
239
|
+
if (!target) {
|
|
240
|
+
reply.status(404).send({error: 'User not found'});
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
if (!canManageUser(request.user, target)) {
|
|
244
|
+
reply.code(403).send({error: 'Your role is not senior to theirs.'});
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return target;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
fastify.post('/users/:id/password-reset', {preHandler: canUpdate, config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
251
|
+
const via = request.body?.via === 'link' ? 'link' : 'email';
|
|
252
|
+
const target = await resetTarget(request, reply);
|
|
253
|
+
if (!target) return reply;
|
|
254
|
+
try {
|
|
255
|
+
const actor = await getUserById(request.user.id).catch(() => null);
|
|
256
|
+
return await adminReset({user: target, origin: await adminOrigin(request), via, by: actor || request.user});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err.code) return reply.status(409).send({error: err.message, code: err.code});
|
|
259
|
+
request.log.error(`[users] password reset for ${target.email} failed: ${err.message}`);
|
|
260
|
+
return reply.status(502).send({error: `The email could not be sent: ${err.message}`});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
fastify.delete('/users/:id/password-reset', {preHandler: canUpdate}, async (request, reply) => {
|
|
265
|
+
const target = await resetTarget(request, reply);
|
|
266
|
+
if (!target) return reply;
|
|
267
|
+
await clearResetToken(target.id);
|
|
268
|
+
return {ok: true};
|
|
269
|
+
});
|
|
270
|
+
|
|
224
271
|
// Delete user
|
|
225
272
|
fastify.delete('/users/:id', {preHandler: canDelete}, async (request, reply) => {
|
|
226
273
|
const { id } = request.params;
|
package/server/server.js
CHANGED
|
@@ -539,10 +539,16 @@ await app.register(dashboardRoutes, {prefix: '/api'});
|
|
|
539
539
|
await app.register(cacheRoutes, {prefix: '/api'});
|
|
540
540
|
await app.register(scaffoldRoutes, {prefix: '/api'});
|
|
541
541
|
// Tools promoted out of plugins/ - their own prefixes, not /api/plugins/*.
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
await
|
|
542
|
+
// Each can be switched off (config/tools.json, services/tools.js): the gate is
|
|
543
|
+
// read per request, so an API stops answering the moment the switch is saved,
|
|
544
|
+
// before the restart that takes its sidebar entry and sources away.
|
|
545
|
+
const {isToolEnabled} = await import('./services/tools.js');
|
|
546
|
+
const {toolGate, toolsRoutes} = await import('./routes/api/tools.js');
|
|
547
|
+
await app.register(toolGate('notes', notesRoutes), {prefix: '/api/notes'});
|
|
548
|
+
await app.register(toolGate('todo', todoRoutes), {prefix: '/api/todo'});
|
|
549
|
+
await app.register(toolGate('analytics', analyticsRoutes), {prefix: '/api/analytics'});
|
|
550
|
+
await app.register(toolGate('contacts', contactsRoutes), {prefix: '/api/contacts'});
|
|
551
|
+
await app.register(toolsRoutes, {prefix: '/api'});
|
|
546
552
|
// What plugins show beside a contact or a note (Calendar's Events) - 0.79.
|
|
547
553
|
await app.register(relatedRoutes, {prefix: '/api/related'});
|
|
548
554
|
|
|
@@ -565,10 +571,21 @@ await app.register(relatedRoutes, {prefix: '/api/related'});
|
|
|
565
571
|
const {registerSidebarItem} = await import('./services/hooks.js');
|
|
566
572
|
// `countUrl` is the badge: the sidebar fetches it and shows {count, label, tone}
|
|
567
573
|
// (see fetchCounts in admin/js/lib/sidebar-renderer.js).
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
574
|
+
// A built-in Tool switched off gets no entry (its switch restarts the site).
|
|
575
|
+
const coreToolItems = [
|
|
576
|
+
{text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'},
|
|
577
|
+
{text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'},
|
|
578
|
+
{text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'},
|
|
579
|
+
{text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}
|
|
580
|
+
];
|
|
581
|
+
for (const item of coreToolItems) {
|
|
582
|
+
if (isToolEnabled(item.permission)) registerSidebarItem({item: {source: 'Built in', ...item}});
|
|
583
|
+
}
|
|
584
|
+
// Todo's to-dos on the calendar (registered when routes/api/todo.js loads).
|
|
585
|
+
if (!isToolEnabled('todo')) {
|
|
586
|
+
const {unregisterCalendarSource} = await import('./services/hooks.js');
|
|
587
|
+
unregisterCalendarSource('todo');
|
|
588
|
+
}
|
|
572
589
|
// Manage projects lives in the menu (the Projects folder, sidebar-migration.js);
|
|
573
590
|
// registering it here only adds its badge - the renderer merges a registered
|
|
574
591
|
// item into the menu node with the same url rather than adding a second one.
|
|
@@ -606,6 +623,8 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search'
|
|
|
606
623
|
// Core's notification sources, before plugins add theirs (0.80).
|
|
607
624
|
const {registerCoreSources} = await import('./services/notification-sources.js');
|
|
608
625
|
await registerCoreSources();
|
|
626
|
+
// "Your password was changed" to the account holder, on every change (services/passwordReset.js).
|
|
627
|
+
(await import('./services/passwordReset.js')).registerPasswordChangeNotice({log: app.log});
|
|
609
628
|
|
|
610
629
|
await registerPlugins(app);
|
|
611
630
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import fs from 'fs';
|
|
19
19
|
import path from 'path';
|
|
20
20
|
import {config, getConfig} from '../config.js';
|
|
21
|
-
import {notify, registerNotificationSource} from './notify.js';
|
|
21
|
+
import {notify, registerNotificationSource, resolveNotification} from './notify.js';
|
|
22
22
|
|
|
23
23
|
const day = (d = new Date()) => d.toISOString().slice(0, 10);
|
|
24
24
|
|
|
@@ -133,6 +133,53 @@ export function recordCrash(err) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/** Register every core source. Called once, before plugins load. */
|
|
136
|
+
/**
|
|
137
|
+
* To-dos (0.87): each person's overdue and due-today to-dos, as at most TWO
|
|
138
|
+
* notices for them (one listing the overdue, one the due today) - never one
|
|
139
|
+
* per task, so twenty overdue tasks are one notice. Completed ones and those
|
|
140
|
+
* with no due day are left out. Pure.
|
|
141
|
+
*
|
|
142
|
+
* @param {object[]} todos - {id, userId, text, status, dueAt}
|
|
143
|
+
* @param {string} today - YYYY-MM-DD (the server's day)
|
|
144
|
+
* @returns {{raise: object[], clear: string[]}} notices, and the keys to resolve (users with nothing in that state)
|
|
145
|
+
*/
|
|
146
|
+
export function todoNotices(todos, today) {
|
|
147
|
+
const endOfDay = new Date(Date.parse(`${today}T00:00:00Z`) + 86400000).toISOString();
|
|
148
|
+
const byUser = new Map();
|
|
149
|
+
for (const t of todos || []) {
|
|
150
|
+
if (!t?.userId) continue;
|
|
151
|
+
if (!byUser.has(t.userId)) byUser.set(t.userId, {overdue: [], today: []});
|
|
152
|
+
if (t.status === 'completed') continue;
|
|
153
|
+
const day = /^(\d{4}-\d{2}-\d{2})/.exec(String(t.dueAt ?? ''))?.[1];
|
|
154
|
+
if (!day) continue;
|
|
155
|
+
if (day < today) byUser.get(t.userId).overdue.push(t);
|
|
156
|
+
else if (day === today) byUser.get(t.userId).today.push(t);
|
|
157
|
+
}
|
|
158
|
+
const name = (t) => String(t.text || 'To-do').replace(/\s+/g, ' ').trim().slice(0, 80) || 'To-do';
|
|
159
|
+
const list = (ts) => ts.slice(0, 5).map(t => `- ${name(t)}`).join('\n') + (ts.length > 5 ? `\n- and ${ts.length - 5} more` : '');
|
|
160
|
+
const raise = [];
|
|
161
|
+
const clear = [];
|
|
162
|
+
for (const [uid, {overdue, today: due}] of byUser) {
|
|
163
|
+
const who = {userIds: [String(uid)]};
|
|
164
|
+
if (overdue.length) {
|
|
165
|
+
raise.push({title: overdue.length === 1 ? `Overdue: ${name(overdue[0])}` : `${overdue.length} to-dos are overdue`,
|
|
166
|
+
body: list(overdue), severity: 'warning', link: '#/todo', dedupeKey: `todo-overdue:${uid}`, audience: who});
|
|
167
|
+
} else clear.push(`todo-overdue:${uid}`);
|
|
168
|
+
if (due.length) {
|
|
169
|
+
raise.push({title: due.length === 1 ? `Due today: ${name(due[0])}` : `${due.length} to-dos are due today`,
|
|
170
|
+
body: list(due), severity: 'info', link: '#/todo', dedupeKey: `todo-today:${uid}:${today}`, audience: who, expiresAt: endOfDay});
|
|
171
|
+
} else clear.push(`todo-today:${uid}:${today}`);
|
|
172
|
+
}
|
|
173
|
+
return {raise, clear};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A person's to-dos changed (completed, deleted, re-dated): if nothing is overdue any more, their notice goes now. */
|
|
177
|
+
export async function todoChanged(userId, todos, today) {
|
|
178
|
+
if (!userId) return;
|
|
179
|
+
const {clear} = todoNotices((todos || []).filter(t => t.userId === userId).concat([{userId}]), today);
|
|
180
|
+
for (const key of clear) await resolveNotification(key, 'core:todo').catch(() => 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
136
183
|
export async function registerCoreSources() {
|
|
137
184
|
const plugins = await import('./plugins.js');
|
|
138
185
|
const {classifyEntitlement} = await import('./pluginEntitlement.js');
|
|
@@ -154,6 +201,22 @@ export async function registerCoreSources() {
|
|
|
154
201
|
}
|
|
155
202
|
});
|
|
156
203
|
|
|
204
|
+
// Todo switched off (config/tools.json): no morning notices either.
|
|
205
|
+
const {isToolEnabled} = await import('./tools.js');
|
|
206
|
+
if (isToolEnabled('todo')) registerNotificationSource({
|
|
207
|
+
id: 'core:todo', label: 'To-dos overdue or due today', severity: 'warning', every: 'daily', atBoot: true,
|
|
208
|
+
description: "Each morning: a person's overdue to-dos, and those due today - one notice each, to that person.",
|
|
209
|
+
async check({notify: raise, resolve, now}) {
|
|
210
|
+
const {listEntries} = await import('./collections.js');
|
|
211
|
+
const {entries} = await listEntries('todo-items', {limit: 100000}).catch(() => ({entries: []}));
|
|
212
|
+
const d = now || new Date();
|
|
213
|
+
const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
214
|
+
const {raise: notices, clear} = todoNotices(entries.map(e => ({id: e.id, ...e.data})), today);
|
|
215
|
+
for (const key of clear) await resolve(key);
|
|
216
|
+
for (const n of notices) await raise(n);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
157
220
|
registerNotificationSource({
|
|
158
221
|
id: 'core:licence', label: 'Plugin licences', severity: 'warning', every: 'daily', atBoot: true,
|
|
159
222
|
description: 'A licensed plugin that has stopped, is in its grace period, or ends within 14 days.',
|
|
@@ -218,6 +281,8 @@ export async function registerCoreSources() {
|
|
|
218
281
|
description: 'The site could not send an email, or has no mail server set up (mail goes nowhere). At most once a day.'},
|
|
219
282
|
{id: 'core:logins', label: 'Repeated failed sign-ins', severity: 'warning',
|
|
220
283
|
description: `${LOGIN_FAILURES.count} failed sign-ins to one account within 15 minutes, or the sign-in limit being hit.`},
|
|
284
|
+
{id: 'core:password-resets', label: 'Password reset not sent', severity: 'warning',
|
|
285
|
+
description: 'Someone asked for a password reset and no email could go: no mail server, or no known address for the site. At most once a day per account.'},
|
|
221
286
|
{id: 'core:roles', label: 'Admin rights given', severity: 'warning',
|
|
222
287
|
description: 'Someone was given the Admin or Super Admin role.'},
|
|
223
288
|
{id: 'core:forms', label: 'Form actions failing', severity: 'warning',
|
|
@@ -279,6 +344,22 @@ export function loginRateLimited(ip) {
|
|
|
279
344
|
body: `${ip || 'One address'} made more than 5 sign-in attempts in a minute and is being refused for now. Repeated, that is someone guessing passwords.`});
|
|
280
345
|
}
|
|
281
346
|
|
|
347
|
+
/**
|
|
348
|
+
* passwordReset.js: a reset was asked for and could not be emailed.
|
|
349
|
+
*
|
|
350
|
+
* @param {'no-smtp'|'no-origin'} reason
|
|
351
|
+
* @param {string} email - the account's address (only ever a real, active account)
|
|
352
|
+
*/
|
|
353
|
+
export function resetNotSent(reason, email) {
|
|
354
|
+
const why = reason === 'no-smtp'
|
|
355
|
+
? 'No mail server is set up (Settings > Email), so the reset link could not be emailed.'
|
|
356
|
+
: 'The site does not know its own address yet, so it could not build a link it trusts. Set the Site URL in Settings › General, or sign in to the admin once at the usual address.';
|
|
357
|
+
return notify({source: 'core:password-resets', severity: 'warning', dedupeKey: `reset-not-sent:${String(email).toLowerCase()}:${day()}`,
|
|
358
|
+
link: reason === 'no-smtp' ? '#/settings' : '#/users',
|
|
359
|
+
title: `A password reset for ${email} was not sent`,
|
|
360
|
+
body: `${email} asked for a password reset. ${why}\n\nYou can set a new password for them in Users.`});
|
|
361
|
+
}
|
|
362
|
+
|
|
282
363
|
/** users route: someone was given admin rights. */
|
|
283
364
|
export function adminRightsGiven(user, roles, by) {
|
|
284
365
|
const which = roles.includes('super-admin') ? 'Super Admin' : 'Admin';
|