domma-cms 0.87.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.
Files changed (37) hide show
  1. package/CLAUDE.md +9 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/http-interceptor.js +1 -1
  6. package/admin/js/lib/login-arrange.js +1 -0
  7. package/admin/js/lib/password-field.js +1 -0
  8. package/admin/js/lib/password-reset-actions.js +12 -0
  9. package/admin/js/lib/plugins-arrange.js +2 -2
  10. package/admin/js/lib/site-settings-arrange.js +1 -1
  11. package/admin/js/lib/users-arrange.js +1 -1
  12. package/admin/js/templates/login.html +33 -5
  13. package/admin/js/templates/plugins.html +7 -0
  14. package/admin/js/templates/settings.html +6 -0
  15. package/admin/js/templates/user-editor.html +7 -0
  16. package/admin/js/views/login.js +2 -2
  17. package/admin/js/views/plugins.js +17 -7
  18. package/admin/js/views/settings.js +4 -4
  19. package/admin/js/views/user-editor.js +1 -1
  20. package/admin/js/views/users.js +3 -2
  21. package/package.json +1 -1
  22. package/plugins/free-tier.lock.json +4 -4
  23. package/plugins/mail-reader/CLAUDE.md +5 -0
  24. package/plugins/mail-reader/plugin.json +5 -2
  25. package/server/routes/api/auth.js +41 -63
  26. package/server/routes/api/plugins.js +33 -3
  27. package/server/routes/api/related.js +6 -3
  28. package/server/routes/api/settings.js +12 -0
  29. package/server/routes/api/tools.js +116 -0
  30. package/server/routes/api/users.js +49 -2
  31. package/server/server.js +27 -8
  32. package/server/services/notification-sources.js +21 -1
  33. package/server/services/passwordReset.js +507 -0
  34. package/server/services/plugins.js +27 -1
  35. package/server/services/renderer.js +5 -2
  36. package/server/services/tools.js +259 -0
  37. 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);
@@ -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
- await app.register(notesRoutes, {prefix: '/api/notes'});
543
- await app.register(todoRoutes, {prefix: '/api/todo'});
544
- await app.register(analyticsRoutes, {prefix: '/api/analytics'});
545
- await app.register(contactsRoutes, {prefix: '/api/contacts'});
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
- registerSidebarItem({item: {source: 'Built in', text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'}});
569
- registerSidebarItem({item: {source: 'Built in', text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'}});
570
- registerSidebarItem({item: {source: 'Built in', text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'}});
571
- registerSidebarItem({item: {source: 'Built in', text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}});
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
 
@@ -201,7 +201,9 @@ export async function registerCoreSources() {
201
201
  }
202
202
  });
203
203
 
204
- registerNotificationSource({
204
+ // Todo switched off (config/tools.json): no morning notices either.
205
+ const {isToolEnabled} = await import('./tools.js');
206
+ if (isToolEnabled('todo')) registerNotificationSource({
205
207
  id: 'core:todo', label: 'To-dos overdue or due today', severity: 'warning', every: 'daily', atBoot: true,
206
208
  description: "Each morning: a person's overdue to-dos, and those due today - one notice each, to that person.",
207
209
  async check({notify: raise, resolve, now}) {
@@ -279,6 +281,8 @@ export async function registerCoreSources() {
279
281
  description: 'The site could not send an email, or has no mail server set up (mail goes nowhere). At most once a day.'},
280
282
  {id: 'core:logins', label: 'Repeated failed sign-ins', severity: 'warning',
281
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.'},
282
286
  {id: 'core:roles', label: 'Admin rights given', severity: 'warning',
283
287
  description: 'Someone was given the Admin or Super Admin role.'},
284
288
  {id: 'core:forms', label: 'Form actions failing', severity: 'warning',
@@ -340,6 +344,22 @@ export function loginRateLimited(ip) {
340
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.`});
341
345
  }
342
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
+
343
363
  /** users route: someone was given admin rights. */
344
364
  export function adminRightsGiven(user, roles, by) {
345
365
  const which = roles.includes('super-admin') ? 'Super Admin' : 'Admin';