domma-cms 0.75.0 → 0.76.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 (66) hide show
  1. package/admin/js/app.js +4 -4
  2. package/admin/js/lib/api-tokens-arrange.js +3 -0
  3. package/admin/js/lib/effects-arrange.js +3 -0
  4. package/admin/js/lib/layouts-arrange.js +1 -0
  5. package/admin/js/lib/my-profile-arrange.js +1 -0
  6. package/admin/js/lib/notifications-arrange.js +1 -0
  7. package/admin/js/lib/plugins-arrange.js +2 -0
  8. package/admin/js/lib/roles-arrange.js +1 -0
  9. package/admin/js/lib/search-settings-arrange.js +1 -0
  10. package/admin/js/lib/sidebar-renderer.js +2 -2
  11. package/admin/js/lib/site-settings-arrange.js +1 -0
  12. package/admin/js/lib/users-arrange.js +1 -0
  13. package/admin/js/templates/api-tokens.html +83 -10
  14. package/admin/js/templates/effects.html +155 -72
  15. package/admin/js/templates/layouts.html +126 -43
  16. package/admin/js/templates/my-profile.html +100 -12
  17. package/admin/js/templates/notifications.html +70 -10
  18. package/admin/js/templates/plugins.html +100 -28
  19. package/admin/js/templates/roles.html +78 -7
  20. package/admin/js/templates/search.html +193 -216
  21. package/admin/js/templates/settings.html +404 -472
  22. package/admin/js/templates/users.html +69 -9
  23. package/admin/js/views/api-tokens.js +62 -8
  24. package/admin/js/views/effects.js +31 -1
  25. package/admin/js/views/layouts.js +81 -1
  26. package/admin/js/views/my-profile.js +1 -1
  27. package/admin/js/views/notifications.js +18 -1
  28. package/admin/js/views/plugins.js +97 -39
  29. package/admin/js/views/roles.js +49 -4
  30. package/admin/js/views/search.js +7 -1
  31. package/admin/js/views/settings.js +13 -3
  32. package/admin/js/views/users.js +13 -4
  33. package/package.json +1 -1
  34. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  35. package/plugins/_lib/admin/mail/contacts.js +4 -1
  36. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  37. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  38. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  39. package/plugins/_lib/admin/mail/mail.css +1 -1
  40. package/plugins/_lib/admin/mail/mail.html +1 -1
  41. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  42. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  43. package/plugins/_lib/mail/readRoutes.js +43 -1
  44. package/plugins/_lib/mail/unreadBadge.js +42 -0
  45. package/plugins/free-tier.lock.json +5 -5
  46. package/plugins/mail-reader/CLAUDE.md +1 -0
  47. package/plugins/mail-reader/plugin.js +3 -1
  48. package/plugins/mail-reader/plugin.json +5 -4
  49. package/server/routes/api/api-tokens.js +56 -0
  50. package/server/routes/api/auth.js +7 -1
  51. package/server/routes/api/collections.js +13 -1
  52. package/server/routes/api/effects.js +68 -13
  53. package/server/routes/api/layouts.js +93 -2
  54. package/server/routes/api/notifications.js +140 -14
  55. package/server/routes/api/plugins.js +135 -4
  56. package/server/routes/api/roles.js +111 -0
  57. package/server/routes/api/search.js +38 -4
  58. package/server/routes/api/settings.js +52 -0
  59. package/server/routes/api/users.js +53 -1
  60. package/server/server.js +12 -0
  61. package/server/services/plugins.js +7 -0
  62. package/server/services/roles.js +3 -0
  63. package/server/services/search.js +58 -0
  64. package/server/services/users.js +14 -0
  65. package/admin/js/templates/plugin-marketplace.html +0 -29
  66. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Users API
3
3
  * GET /api/users - list all users (admin, manager)
4
+ * GET /api/users/_count - the sidebar badge (ids are uuids - nothing can shadow it)
4
5
  * GET /api/users/:id - single user (admin, manager, or self)
5
6
  * POST /api/users - create user (admin, manager - manager cannot create admin)
6
7
  * PUT /api/users/:id - update user (admin, manager - manager cannot edit admin)
@@ -10,6 +11,45 @@ import {authenticate, canManageUser, requirePermission} from '../../middleware/a
10
11
  import {getPermissionsFor} from '../../services/roles.js';
11
12
  import {createUser, deleteUser, getUserById, listUsers, updateUser} from '../../services/users.js';
12
13
  import {getProfile, listProfiles, updateProfile} from '../../services/userProfiles.js';
14
+ import {ago, clip, plural} from '../../services/sidebarBadges.js';
15
+
16
+ /**
17
+ * The sidebar badge on Users (its countUrl, GET /api/users/_count). The number
18
+ * stays what the sidebar has always shown there - the users this reader can
19
+ * see - and the popover says who is inactive, who has never signed in, and
20
+ * who signed in last. Plain text throughout (the popover uses textContent);
21
+ * never anything from the password or tokens.
22
+ *
23
+ * @param {object[]} users - as the list route returns them (password stripped)
24
+ * @param {number} [now]
25
+ */
26
+ export function usersBadge(users = [], now = Date.now()) {
27
+ const list = users || [];
28
+ const inactive = list.filter(u => u.isActive === false);
29
+ const never = list.filter(u => !u.lastLogin);
30
+ const week = list.filter(u => u.lastLogin && now - new Date(u.lastLogin).getTime() < 7 * 86_400_000);
31
+ const recent = list.filter(u => u.lastLogin)
32
+ .sort((a, b) => String(b.lastLogin).localeCompare(String(a.lastLogin)));
33
+ return {
34
+ count: list.length,
35
+ label: `${plural(list.length, 'user')}${inactive.length ? `, ${inactive.length} inactive` : ''}`,
36
+ tone: 'info',
37
+ details: {
38
+ title: 'Users',
39
+ rows: [
40
+ {label: 'Active', value: String(list.length - inactive.length)},
41
+ {label: 'Inactive', value: String(inactive.length)},
42
+ {label: 'Signed in this week', value: String(week.length)},
43
+ {label: 'Never signed in', value: String(never.length)}
44
+ ],
45
+ items: recent.slice(0, 6).map(u => ({
46
+ text: clip(u.name || u.email || 'User'),
47
+ meta: [u.role, `signed in ${ago(u.lastLogin, now)}`].filter(Boolean).join(' · ')
48
+ })),
49
+ empty: 'Nobody has signed in yet.'
50
+ }
51
+ };
52
+ }
13
53
 
14
54
  export async function usersRoutes(fastify) {
15
55
  const canRead = [authenticate, requirePermission('users', 'read')];
@@ -27,6 +67,12 @@ export async function usersRoutes(fastify) {
27
67
  .map(u => ({...u, profile: profiles.get(u.id) || {}}));
28
68
  });
29
69
 
70
+ // The sidebar badge - the same users the list shows this reader. Before /users/:id.
71
+ fastify.get('/users/_count', {preHandler: canRead}, async (request) => {
72
+ const {canSeeArtefact} = await import('../../services/projects.js');
73
+ return usersBadge((await listUsers()).filter(u => canSeeArtefact(request.user, u)));
74
+ });
75
+
30
76
  // Get single user (admin, manager, or the user themselves)
31
77
  fastify.get('/users/:id', { preHandler: [authenticate] }, async (request, reply) => {
32
78
  const { id } = request.params;
@@ -119,7 +165,13 @@ export async function usersRoutes(fastify) {
119
165
 
120
166
  // Strip profile from core updates and handle separately
121
167
  const {profile, ...coreUpdates} = updates;
122
- const user = await updateUser(id, coreUpdates);
168
+ let user;
169
+ try {
170
+ user = await updateUser(id, coreUpdates);
171
+ } catch (err) {
172
+ if (err.code === 'EMAIL_TAKEN') return reply.status(409).send({error: err.message});
173
+ throw err;
174
+ }
123
175
 
124
176
  if (profile && typeof profile === 'object') {
125
177
  await updateProfile(id, profile);
package/server/server.js CHANGED
@@ -440,6 +440,7 @@ const {menusRoutes} = await import('./routes/api/menus.js');
440
440
  const {contextMenusRoutes} = await import('./routes/api/context-menus.js');
441
441
  const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
442
442
  const {projectsRoutes} = await import('./routes/api/projects.js');
443
+ const {rolesRoutes} = await import('./routes/api/roles.js');
443
444
  const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
444
445
  const {apiEndpointsRoutes} = await import('./routes/api/api-endpoints.js');
445
446
  const {endpointsPublicRoutes} = await import('./routes/api/endpoints-public.js');
@@ -475,6 +476,7 @@ await app.register(contextMenusRoutes, {prefix: '/api'});
475
476
  await app.register(themeRoutes, {prefix: '/api'});
476
477
  await app.register(menuLocationsRoutes, {prefix: '/api'});
477
478
  await app.register(projectsRoutes, {prefix: '/api'});
479
+ await app.register(rolesRoutes, {prefix: '/api'});
478
480
  await app.register(apiTokensRoutes, {prefix: '/api'});
479
481
  await app.register(apiEndpointsRoutes, {prefix: '/api'});
480
482
  await app.register(endpointsPublicRoutes, {prefix: '/api'});
@@ -544,6 +546,16 @@ registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Actions',
544
546
  registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints', countUrl: '/api/api-endpoints/_count', inventory: true}});
545
547
  registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages', countUrl: '/api/blocks/_count', inventory: true}});
546
548
  registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Components', url: '#/components', icon: 'component', permission: 'components', countUrl: '/api/components/_count', inventory: true}});
549
+ // System. Notifications, Site Settings, Layouts and API Tokens are signals (unread, something quietly
550
+ // wrong, pages on a missing layout, tokens expiring), so not `inventory`.
551
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Notifications', url: '#/system/notifications', icon: 'bell', permission: 'notifications', countUrl: '/api/system/notifications/_count'}});
552
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Roles', url: '#/roles', icon: 'shield', permission: 'plugins', countUrl: '/api/roles/_count', inventory: true}});
553
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Users', url: '#/users', icon: 'users', permission: 'users', countUrl: '/api/users/_count', inventory: true}});
554
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings', countUrl: '/api/settings/_count'}});
555
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts', countUrl: '/api/layouts/_count'}});
556
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
557
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
558
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
547
559
 
548
560
  await registerPlugins(app);
549
561
 
@@ -266,6 +266,13 @@ async function loadConfigDefaults(name) {
266
266
  if (_configDefaultsCache.has(name)) return _configDefaultsCache.get(name);
267
267
  const configJsPath = path.join(PLUGINS_DIR, name, 'config.js');
268
268
  let defaults = {};
269
+ // No config.js is "no defaults", not a failure: the Marketplace asks for every
270
+ // installed plugin's settings (enabled or not), and recording a load failure for
271
+ // one without the file would put it on the health report for being looked at.
272
+ try { await fs.access(configJsPath); } catch {
273
+ _configDefaultsCache.set(name, defaults);
274
+ return defaults;
275
+ }
269
276
  try {
270
277
  const {default: exported} = await import(configJsPath);
271
278
  defaults = exported || {};
@@ -26,6 +26,9 @@ const USERS_DIR = path.resolve(path.dirname(COLLECTIONS_DIR), 'users');
26
26
  /** Base role slugs that should always exist */
27
27
  const BASE_ROLE_NAMES = ['super-admin', 'admin', 'user'];
28
28
 
29
+ /** The seeded roles every site relies on - never deletable, whatever route is asked. */
30
+ export const isBaseRole = (name) => BASE_ROLE_NAMES.includes(name);
31
+
29
32
  /** Role slugs that were seeded by the old 4-role system - used to detect legacy data */
30
33
  const DEFUNCT_ROLE_NAMES = ['manager', 'editor', 'subscriber'];
31
34
 
@@ -411,3 +411,61 @@ export function searchPages(pages, query, {maxResults = 10, onError = () => {}}
411
411
  results.sort((a, b) => b.score - a.score);
412
412
  return results.slice(0, maxResults);
413
413
  }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // The admin screen: coverage, and the sidebar badge
417
+ // ---------------------------------------------------------------------------
418
+
419
+ /**
420
+ * Which pages search can return, and which it leaves out - the same rule
421
+ * searchPages() applies (drafts and private pages are never results).
422
+ *
423
+ * @param {object[]} pages
424
+ * @returns {{searchable: number, drafts: number, private: number, total: number}}
425
+ */
426
+ export function searchCoverage(pages = []) {
427
+ let drafts = 0;
428
+ let priv = 0;
429
+ for (const p of pages) {
430
+ if (p?.status === 'draft') drafts++;
431
+ else if (p?.visibility === 'private') priv++;
432
+ }
433
+ return {searchable: pages.length - drafts - priv, drafts, private: priv, total: pages.length};
434
+ }
435
+
436
+ /**
437
+ * The sidebar badge on System > Search (its countUrl, GET /api/search/_count):
438
+ * how many pages a visitor's search can find, with whether search is on at all
439
+ * and how it is reached in the popover. An inventory, not a signal - plain text
440
+ * throughout, the popover sets it with textContent.
441
+ *
442
+ * @param {object} settings - normalised search settings
443
+ * @param {object[]} pages
444
+ */
445
+ export function searchBadge(settings = SEARCH_DEFAULTS, pages = []) {
446
+ const s = normaliseSearchSettings(settings);
447
+ const c = searchCoverage(pages);
448
+ const pl = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
449
+ const trigger = s.displayMode === 'floating'
450
+ ? `Floating button, ${s.position.replace('-', ' ')}`
451
+ : 'Icon in the navigation bar';
452
+ return {
453
+ count: c.searchable,
454
+ label: s.enabled
455
+ ? `Search is on - ${pl(c.searchable, 'page')} searchable`
456
+ : `Search is off - ${pl(c.searchable, 'page')} would be searchable`,
457
+ tone: 'info',
458
+ details: {
459
+ title: 'Site search',
460
+ rows: [
461
+ {label: 'Status', value: s.enabled ? 'On' : 'Off'},
462
+ {label: 'Trigger', value: s.enabled ? trigger : '-'},
463
+ {label: 'Keyboard shortcut', value: s.enabled && s.keyboardShortcut ? 'Ctrl+K / ⌘K' : 'Off'},
464
+ {label: 'Searchable pages', value: String(c.searchable)},
465
+ {label: 'Left out (drafts, private)', value: String(c.drafts + c.private)}
466
+ ],
467
+ items: [],
468
+ empty: s.enabled ? 'Visitors can search every published, public page.' : 'Search is switched off - nothing is added to public pages.'
469
+ }
470
+ };
471
+ }
@@ -168,6 +168,20 @@ export async function updateUser(id, updates) {
168
168
  const user = await readUserFile(id);
169
169
  if (!user) throw new Error('User not found');
170
170
 
171
+ // An email is how an account signs in: two accounts must never share one.
172
+ // createUser always refused a taken address; an update did not check at all.
173
+ if (typeof updates.email === 'string' && updates.email.trim()) {
174
+ updates.email = updates.email.toLowerCase().trim();
175
+ if (updates.email !== String(user.email || '').toLowerCase()) {
176
+ const other = await getUserByEmail(updates.email);
177
+ if (other && other.id !== user.id) {
178
+ const err = new Error('A user with that email already exists');
179
+ err.code = 'EMAIL_TAKEN';
180
+ throw err;
181
+ }
182
+ }
183
+ }
184
+
171
185
  if (updates.password) {
172
186
  updates.password = await bcrypt.hash(updates.password, config.auth.bcryptRounds);
173
187
  }
@@ -1,29 +0,0 @@
1
- <div class="view-header">
2
- <h2><span data-icon="store"></span> Browse</h2>
3
- <p class="text-muted">Plugins available to this site.</p>
4
- </div>
5
-
6
- <!--
7
- Shown when this site is not managed. Telling an unmanaged customer to set
8
- MANAGER_URL is telling them to configure something they will never have: a
9
- standalone purchase arrives as a file, and Install from File is the whole
10
- procedure. The managed path is mentioned second because it is not theirs to
11
- set up.
12
- -->
13
- <div id="marketplace-unavailable" style="display:none">
14
- <div class="card">
15
- <div class="card-body">
16
- <p><strong>This site installs plugins from a file.</strong></p>
17
- <p class="text-muted">Buy a plugin and you are sent a single <code>.dcmsplugin</code> file. Use
18
- <strong>Install from File</strong> above to install it - no connection to anything is needed, and the
19
- same button takes an update later.</p>
20
- <p class="text-muted" style="margin-bottom:0">Sites managed for you browse and install here instead, over their
21
- manager connection.</p>
22
- </div>
23
- </div>
24
- </div>
25
-
26
- <div id="marketplace-available" style="display:none">
27
- <p id="marketplace-site" class="text-muted" style="display:none;font-size:.85rem;margin:0 0 .75rem"></p>
28
- <div id="marketplace-grid" class="card-grid"></div>
29
- </div>
@@ -1 +0,0 @@
1
- import{api as u}from"../api.js";import{pluginAccent as P,pluginWatermark as S}from"../lib/plugin-accent.js";export const pluginMarketplaceView={templateUrl:"/admin/js/templates/plugin-marketplace.html",async onMount(e){const a=E.loader(e.get(0),{type:"dots"});let t;try{t=await u.marketplace.catalogue()}catch(s){E.toast(`Failed to load marketplace: ${s.message}`,{type:"error"}),a.destroy();return}if(a.destroy(),!t.available){e.find("#marketplace-unavailable").show();return}e.find("#marketplace-available").show(),t.site&&e.find("#marketplace-site").text(`Licensed to ${t.site}.`).show(),U(e,t.catalogue),Domma.icons.scan()}};function U(e,a){const t=e.find("#marketplace-grid").get(0);if(t){for(;t.firstChild;)t.removeChild(t.firstChild);if(!a||a.length===0){const s=document.createElement("p");s.className="text-muted",s.textContent="No plugins available in the marketplace.",t.appendChild(s);return}a.forEach(s=>{const i=R(s,e);t.appendChild(i)})}}function F(e){if(e.tier!=="paid")return"Free";const a=e.terms?.managed?.monthly;return a==null||!Number.isFinite(Number(a))?"Paid":`${e.terms?.currency==="GBP"?"\xA3":(e.terms?.currency||"")+" "}${(Number(a)/100).toFixed(2)}/mo`}function R(e,a){const{slug:t,version:s,displayName:i,description:y,entitled:o,installed:l,installedVersion:p,updatable:b}=e,r=document.createElement("div");r.className="card plugin-card",r.dataset.slug=t,r.style.setProperty("--plugin-accent",P(t)),r.appendChild(S(e.icon));const h=document.createElement("div");h.className="card-body";const m=document.createElement("div");m.className="plugin-header";const N=document.createElement("div");N.className="plugin-icon";const I=document.createElement("span");I.dataset.icon=e.icon||"package",N.appendChild(I);const g=document.createElement("div");g.className="plugin-meta";const x=document.createElement("div");x.className="plugin-name",x.textContent=i||t;const k=document.createElement("div");k.className="plugin-version",k.textContent=l&&p&&p!==s?`v${p} \u2192 v${s}`:`v${s}`,g.appendChild(x),g.appendChild(k);const f=document.createElement("div");f.style.cssText="display:flex;gap:.35rem;align-items:center;";const w=document.createElement("span");if(w.className="badge badge-outline",w.textContent=F(e),f.appendChild(w),o&&e.tier==="paid"){const c=document.createElement("span");c.className="badge badge-success",c.textContent=e.licenceStatus==="grace"?"Renew":"Licensed",c.title=e.licenceStatus==="grace"?"The rental term has ended - still running during the grace period":"This site holds a live licence",f.appendChild(c)}m.appendChild(N),m.appendChild(g),m.appendChild(f);const $=document.createElement("p");$.className="plugin-desc",$.textContent=y||"",h.appendChild(m),h.appendChild($);const C=document.createElement("div");C.className="plugin-footer";const v=document.createElement("span");v.className="text-muted",v.style.fontSize=".8rem",v.textContent="Restart server after changes",C.appendChild(v);const d=document.createElement("div");d.className="plugin-footer-actions";const n=document.createElement("button");if(n.className="btn btn-sm",l&&b&&o){n.className+=" btn-primary",n.textContent="Update",n.onclick=()=>T(e,a,{update:!0}),d.appendChild(n);const c=document.createElement("button");c.className="btn btn-sm btn-ghost",c.textContent="Uninstall",c.onclick=()=>D(t,a),d.appendChild(c)}else l?(n.className+=" btn-ghost",n.textContent="Uninstall",n.onclick=()=>D(t,a),d.appendChild(n)):o?(n.className+=" btn-primary",n.textContent="Install",n.onclick=()=>T(e,a),d.appendChild(n)):(n.className+=" btn-ghost",n.textContent=e.licenceStatus==="expired"?"Licence expired":"Not licensed",n.disabled=!0,n.title="Contact us to add this to your site.",d.appendChild(n));return C.appendChild(d),r.appendChild(h),r.appendChild(C),r}async function T(e,a,{update:t=!1}={}){const{slug:s,version:i,installedVersion:y}=e,o=t?`<p>Update ${s} from ${y} to ${i}?</p><p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${e.displayName||s} v${i}?</p><p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(o,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0}))try{const l=await u.marketplace.install(s,i,{update:t}),p=l?.action==="upgrade"?`${s} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${s} ${l?.version??i} installed.`;E.toast(`${p} Restart the server to activate it.`,{type:"success"});const b=await u.marketplace.catalogue();U(a,b.catalogue),Domma.icons.scan()}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}}async function D(e,a){if(await E.confirm(`Uninstall ${e}? This will remove its files.`))try{await u.marketplace.uninstall(e),E.toast(`${e} uninstalled. Restart to apply.`,{type:"success"});const t=await u.marketplace.catalogue();U(a,t.catalogue),Domma.icons.scan()}catch(t){E.toast(`Uninstall failed: ${t.message}`,{type:"error"})}}