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
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Switching Tools on and off - the built-in ones and plugins alike.
3
+ *
4
+ * ## Built-in Tools
5
+ *
6
+ * Contacts, Notes, Todo and Analytics were plugins until 0.67.0. They are part
7
+ * of the CMS now and ON unless a site says otherwise, in `config/tools.json`:
8
+ *
9
+ * { "notes": { "enabled": false, "locked": true } }
10
+ *
11
+ * A Tool that is off keeps its data. Its API answers 404 (server.js gates the
12
+ * prefix at request time), its sidebar entry is not registered, and anything
13
+ * it feeds - notification sources, calendar sources, related items, the
14
+ * analytics beacon - is left out.
15
+ *
16
+ * ## Dependencies
17
+ *
18
+ * A plugin's manifest says what it needs:
19
+ *
20
+ * "requires": ["contacts"] - cannot work without it
21
+ * "uses": {"contacts": "Invite a contact group"} - loses that feature without it
22
+ *
23
+ * Either may name a built-in Tool or another plugin. `supersedes` is a
24
+ * different relationship (a replacement) and is not a dependency.
25
+ *
26
+ * `planSwitch()` is the one place that decides what a change does: which
27
+ * plugins go off with a Tool they require, which requirements come on with a
28
+ * plugin, what a soft dependant loses, and what is refused. It is pure so the
29
+ * Manager can run it against a site on disk before writing anything, and the
30
+ * site runs the same function when its own admin flips a switch. The loader
31
+ * (`unmetRequirements()`) is the backstop: a plugin whose requirement is off
32
+ * is not loaded, whatever plugins.json says.
33
+ *
34
+ * ## Locks
35
+ *
36
+ * `locked: true` - on a tools.json or a plugins.json entry - means the site's
37
+ * own admin cannot change that switch; only its Manager, which writes the
38
+ * files directly. A cascade that would move a locked switch is refused.
39
+ *
40
+ * @module services/tools
41
+ */
42
+ import {getConfig, saveConfig} from '../config.js';
43
+
44
+ /** The built-in Tools that can be switched off. Order is display order. */
45
+ export const CORE_TOOLS = Object.freeze([
46
+ {name: 'contacts', displayName: 'Contacts', icon: 'users', description: 'An address book for each user, with groups.'},
47
+ {name: 'notes', displayName: 'Notes', icon: 'edit-3', description: 'Private notes for each user.'},
48
+ {name: 'todo', displayName: 'Todo', icon: 'check-square', description: 'To-do lists with due dates and reminders.'},
49
+ {name: 'analytics', displayName: 'Analytics', icon: 'chart-bar', description: 'Page views and visitors, counted on this server.'}
50
+ ]);
51
+
52
+ const CORE_NAMES = new Set(CORE_TOOLS.map(t => t.name));
53
+
54
+ /** @param {string} name @returns {boolean} */
55
+ export const isCoreTool = (name) => CORE_NAMES.has(name);
56
+
57
+ /**
58
+ * Stored state of the built-in Tools. Missing file or entry = on, unlocked.
59
+ * Read fresh each call: the Manager writes this file from outside the process.
60
+ *
61
+ * @returns {Object.<string, {enabled?: boolean, locked?: boolean}>}
62
+ */
63
+ export function getToolStates() {
64
+ try {
65
+ const raw = getConfig('tools');
66
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ /** @param {string} name @returns {boolean} a built-in Tool is on unless tools.json says otherwise */
73
+ export function isToolEnabled(name) {
74
+ return getToolStates()[name]?.enabled !== false;
75
+ }
76
+
77
+ /** @param {string} name @returns {boolean} */
78
+ export function isToolLocked(name) {
79
+ return getToolStates()[name]?.locked === true;
80
+ }
81
+
82
+ /**
83
+ * @param {string} name
84
+ * @param {{enabled?: boolean, locked?: boolean}} patch
85
+ */
86
+ export function saveToolState(name, patch) {
87
+ if (!isCoreTool(name)) throw new Error(`"${name}" is not a built-in Tool`);
88
+ const states = getToolStates();
89
+ states[name] = {...states[name], ...patch};
90
+ saveConfig('tools', states);
91
+ }
92
+
93
+ /** `requires` from a manifest, as a clean list of names. */
94
+ export function requiresOf(manifest) {
95
+ const r = manifest?.requires;
96
+ return Array.isArray(r) ? r.filter(n => typeof n === 'string' && n) : [];
97
+ }
98
+
99
+ /** `uses` from a manifest, as {name: reason}. An array form is accepted too. */
100
+ export function usesOf(manifest) {
101
+ const u = manifest?.uses;
102
+ if (Array.isArray(u)) return Object.fromEntries(u.filter(n => typeof n === 'string').map(n => [n, '']));
103
+ if (!u || typeof u !== 'object') return {};
104
+ return Object.fromEntries(Object.entries(u).map(([k, v]) => [k, typeof v === 'string' ? v : '']));
105
+ }
106
+
107
+ /**
108
+ * Everything that can be switched, as one list the planner reads.
109
+ *
110
+ * @param {{manifests?: object[], pluginStates?: object, toolStates?: object}} input
111
+ * @returns {Map<string, {name: string, kind: 'tool'|'plugin', displayName: string, enabled: boolean,
112
+ * locked: boolean, requires: string[], uses: Object.<string,string>, supersedes: string[], closedSource: boolean}>}
113
+ */
114
+ export function switchableUnits({manifests = [], pluginStates = {}, toolStates = {}} = {}) {
115
+ const units = new Map();
116
+ for (const t of CORE_TOOLS) {
117
+ const s = toolStates[t.name] || {};
118
+ units.set(t.name, {name: t.name, kind: 'tool', displayName: t.displayName, icon: t.icon,
119
+ enabled: s.enabled !== false, locked: s.locked === true, requires: [], uses: {}, supersedes: [], closedSource: false});
120
+ }
121
+ for (const m of manifests) {
122
+ if (!m?.name || units.has(m.name)) continue;
123
+ const s = pluginStates[m.name] || {};
124
+ units.set(m.name, {name: m.name, kind: 'plugin', displayName: m.displayName || m.name, icon: m.icon || 'package',
125
+ enabled: s.enabled === true, locked: s.locked === true, requires: requiresOf(m), uses: usesOf(m),
126
+ supersedes: Array.isArray(m.supersedes) ? m.supersedes : [], closedSource: m.closedSource === true});
127
+ }
128
+ return units;
129
+ }
130
+
131
+ /** The name as people read it. */
132
+ const label = (units, name) => units.get(name)?.displayName || name;
133
+
134
+ /**
135
+ * What a set of switch changes would do.
136
+ *
137
+ * - Switching off something an enabled plugin REQUIRES switches that plugin
138
+ * off too (a cascade), recursively.
139
+ * - Switching on a plugin switches on what it requires, recursively.
140
+ * - A soft dependant (`uses`) stays on and the feature it loses is listed.
141
+ * - A locked switch cannot be moved unless `asManager`; a request or cascade
142
+ * that would move one is refused.
143
+ * - A requirement that is not installed at all refuses the plugin.
144
+ *
145
+ * Only dependencies touched by THIS change are followed: a plugin that was
146
+ * already enabled with a requirement off (a broken state the loader refuses
147
+ * to run) is left as it is.
148
+ *
149
+ * @param {Map} units from switchableUnits()
150
+ * @param {Object.<string, boolean>} requested name -> on/off
151
+ * @param {{asManager?: boolean}} [opts]
152
+ * @returns {{ok: boolean, changes: Object.<string, boolean>, cascaded: object[], losses: object[], refused: object[]}}
153
+ */
154
+ export function planSwitch(units, requested = {}, {asManager = false} = {}) {
155
+ const next = new Map([...units].map(([n, u]) => [n, u.enabled]));
156
+ const was = (n) => units.get(n)?.enabled === true;
157
+ const may = (n) => asManager || !units.get(n)?.locked;
158
+ const refused = [];
159
+ const cascaded = [];
160
+ const cascadeOn = new Set();
161
+
162
+ for (const [name, on] of Object.entries(requested)) {
163
+ const u = units.get(name);
164
+ if (!u) { refused.push({name, reason: 'is not installed on this site'}); continue; }
165
+ if (!!on === u.enabled) continue;
166
+ if (!may(name)) { refused.push({name, reason: 'is locked by the site\'s manager'}); continue; }
167
+ next.set(name, !!on);
168
+ }
169
+ const askedOn = (n) => requested[n] === true || cascadeOn.has(n);
170
+
171
+ // Settle to a fixed point: each pass may switch something that changes
172
+ // what another needs. Bounded - each unit changes direction at most once.
173
+ for (let pass = 0, changed = true; changed && pass <= units.size + 1; pass++) {
174
+ changed = false;
175
+ for (const [name, u] of units) {
176
+ if (next.get(name) !== true) continue;
177
+ for (const r of u.requires) {
178
+ if (next.get(r) === true) continue;
179
+ const turningOn = !was(name);
180
+ // Only follow a dependency this change touches.
181
+ if (!turningOn && !was(r)) continue;
182
+ if (turningOn && askedOn(name)) {
183
+ const ru = units.get(r);
184
+ if (!ru) {
185
+ refused.push({name, reason: `needs ${r}, which is not installed`});
186
+ } else if (requested[r] === false) {
187
+ refused.push({name, reason: `needs ${label(units, r)}, which is being switched off`});
188
+ } else if (!may(r)) {
189
+ refused.push({name, reason: `needs ${label(units, r)}, which is locked off`});
190
+ } else {
191
+ next.set(r, true);
192
+ cascadeOn.add(r);
193
+ cascaded.push({name: r, displayName: label(units, r), enabled: true, because: name, becauseName: label(units, name)});
194
+ changed = true;
195
+ continue;
196
+ }
197
+ next.set(name, false);
198
+ changed = true;
199
+ break;
200
+ }
201
+ // r is going off (or never came on): name has to follow it.
202
+ if (!may(name)) {
203
+ refused.push({name: r, reason: `${label(units, name)} needs it and is locked on`});
204
+ next.set(r, true);
205
+ } else {
206
+ next.set(name, false);
207
+ cascaded.push({name, displayName: label(units, name), enabled: false, because: r, becauseName: label(units, r)});
208
+ }
209
+ changed = true;
210
+ break;
211
+ }
212
+ }
213
+ }
214
+
215
+ // A cascade may have been undone by a later refusal; keep only real moves.
216
+ const changes = {};
217
+ for (const [n, on] of next) if (on !== was(n)) changes[n] = on;
218
+ const real = cascaded.filter(c => changes[c.name] === c.enabled);
219
+
220
+ const losses = [];
221
+ for (const [name, u] of units) {
222
+ if (next.get(name) !== true) continue;
223
+ for (const [t, reason] of Object.entries(u.uses)) {
224
+ if (was(t) && next.get(t) === false) {
225
+ losses.push({name, displayName: u.displayName, tool: t, toolName: label(units, t), reason});
226
+ }
227
+ }
228
+ }
229
+
230
+ return {ok: refused.length === 0, changes, cascaded: real, losses, refused};
231
+ }
232
+
233
+ /**
234
+ * Which enabled plugins cannot run because something they require is not
235
+ * running. `active` is the set that would load (after entitlement and
236
+ * supersession); built-in Tools are active when switched on.
237
+ *
238
+ * @param {object[]} manifests
239
+ * @param {Set<string>} active plugin names that would otherwise load
240
+ * @param {(name: string) => boolean} toolOn
241
+ * @returns {Map<string, string>} plugin -> the requirement it is missing
242
+ */
243
+ export function unmetRequirements(manifests, active, toolOn = isToolEnabled) {
244
+ const byName = new Map(manifests.map(m => [m.name, m]));
245
+ const running = new Set(active);
246
+ const blocked = new Map();
247
+ for (let changed = true; changed;) {
248
+ changed = false;
249
+ for (const name of running) {
250
+ const missing = requiresOf(byName.get(name)).find(r => (isCoreTool(r) ? !toolOn(r) : !running.has(r)));
251
+ if (missing) {
252
+ running.delete(name);
253
+ blocked.set(name, missing);
254
+ changed = true;
255
+ }
256
+ }
257
+ }
258
+ return blocked;
259
+ }
@@ -28,6 +28,8 @@ async function ensureDir() {
28
28
  */
29
29
  function stripPassword(user) {
30
30
  const {password, resetTokenHash, resetTokenExpiry, ...safe} = user;
31
+ // When an unused reset link is still open - the time only, never the token.
32
+ if (resetTokenHash && Date.parse(resetTokenExpiry) > Date.now()) safe.resetLinkExpires = resetTokenExpiry;
31
33
  return safe;
32
34
  }
33
35
 
@@ -182,6 +184,12 @@ export async function updateUser(id, updates) {
182
184
  }
183
185
  }
184
186
 
187
+ // Reset tokens are written by setResetToken/clearResetToken only - never by an
188
+ // update, which takes whole records from clients.
189
+ delete updates.resetTokenHash;
190
+ delete updates.resetTokenExpiry;
191
+ delete updates.resetLinkExpires;
192
+
185
193
  if (updates.password) {
186
194
  updates.password = await bcrypt.hash(updates.password, config.auth.bcryptRounds);
187
195
  }