domma-cms 0.87.0 → 0.88.1

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 (47) 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 +94 -44
  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 +1 -11
  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 +13 -12
  23. package/plugins/mail-reader/CLAUDE.md +5 -0
  24. package/plugins/mail-reader/plugin.json +5 -2
  25. package/plugins/security/CLAUDE.md +7 -0
  26. package/plugins/security/admin/lib/resets.js +82 -0
  27. package/plugins/security/admin/lib/settings.js +1 -1
  28. package/plugins/security/admin/templates/security.html +3 -0
  29. package/plugins/security/admin/views/security.js +23 -10
  30. package/plugins/security/plugin.js +39 -1
  31. package/plugins/security/plugin.json +2 -2
  32. package/plugins/security/tests/security.test.js +34 -0
  33. package/server/routes/api/auth.js +41 -63
  34. package/server/routes/api/forms.js +3 -0
  35. package/server/routes/api/plugins.js +33 -3
  36. package/server/routes/api/related.js +6 -3
  37. package/server/routes/api/settings.js +12 -0
  38. package/server/routes/api/tools.js +116 -0
  39. package/server/routes/api/users.js +49 -2
  40. package/server/server.js +27 -8
  41. package/server/services/notification-sources.js +21 -1
  42. package/server/services/passwordReset.js +507 -0
  43. package/server/services/plugins.js +27 -1
  44. package/server/services/renderer.js +5 -2
  45. package/server/services/siteGitignore.js +114 -3
  46. package/server/services/tools.js +259 -0
  47. package/server/services/users.js +8 -0
@@ -1,4 +1,4 @@
1
- import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-chrome.js";import{helpPopovers as Te}from"../lib/help-popover.js";import{onceClosed as He,openMenuAt as Ie,sidebarCount as Ye,viewLifetime as Je}from"../lib/tool-kit.js";import{pluginAccent as Ne}from"../lib/plugin-accent.js";import{reportRestart as Qe}from"../lib/server-restart.js";import{FILTERS as Ge,arrangeCatalogue as Xe,arrangePlugins as Ze,catalogueAction as oe,catalogueRow as et,checkScaffold as tt,fmtReleased as at,needsAttention as nt,pluginRow as st,pluginStatus as Ue,readSettings as lt,settingsFields as Oe,slugifyPlugin as it,updateFor as Ae}from"../lib/plugins-arrange.js";const Le=/^#\/plugins\/?(\?.*)?$/,ot="#/plugins",re="plugins.filter",X="plugins.draft",rt=`
1
+ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{onceClosed as Xe,openMenuAt as Ae,sidebarCount as Ze,viewLifetime as et}from"../lib/tool-kit.js";import{pluginAccent as Le}from"../lib/plugin-accent.js";import{reportRestart as tt}from"../lib/server-restart.js";import{FILTERS as at,arrangeCatalogue as nt,arrangePlugins as st,catalogueAction as ce,catalogueRow as lt,checkScaffold as it,fmtReleased as ot,needsAttention as rt,planLines as ct,pluginRow as dt,pluginStatus as Ue,readSettings as ut,settingsFields as Re,slugifyPlugin as pt,updateFor as je}from"../lib/plugins-arrange.js";const Be=/^#\/plugins\/?(\?.*)?$/,mt="#/plugins",de="plugins.filter",ee="plugins.draft",bt=`
2
2
  <div class="plx-row" data-name="{{name}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
3
3
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
4
4
  <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
@@ -6,10 +6,20 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
6
6
  <span class="plx-chip is-warn" data-bind-hidden="!update" data-bind-text="updateText"></span>
7
7
  <span class="plx-chip" data-bind-hidden="!entText" data-bind-class="entCls" data-bind-text="entText" title="{{entTitle}}"></span>
8
8
  <span class="plx-chip is-info" data-bind-hidden="!licensed" title="Licensed - its source is not viewable here">Licensed</span>
9
+ <span class="plx-chip is-info" data-bind-hidden="!locked" title="Your site's manager holds this switch">Locked</span>
9
10
  <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
10
11
  </span>
11
12
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
12
- </div>`,ct=`
13
+ </div>`,vt=`
14
+ <div class="plx-row" data-tool="{{name}}" tabindex="0" data-bind-class="rowCls">
15
+ <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
16
+ <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
17
+ <span class="plx-chips">
18
+ <span class="plx-chip is-info" data-bind-hidden="!locked" title="Your site's manager holds this switch">Locked</span>
19
+ <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
20
+ </span>
21
+ <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
22
+ </div>`,ft=`
13
23
  <div class="plx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
14
24
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
15
25
  <span class="plx-main"><strong>{{displayName}}</strong><small>{{meta}}</small></span>
@@ -19,7 +29,7 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
19
29
  </span>
20
30
  <button type="button" class="btn btn-sm plx-act" data-bind-class="actCls" data-bind-disabled="actDisabled" data-bind-text="actionText"></button>
21
31
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
22
- </div>`,r=O=>String(O??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ce(O){const p=document.createElement("template");return p.innerHTML=O.trim(),p.content.firstElementChild}const h=Je(Le);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(O){h.reset();const p=O.get(0).querySelector(".plx")||O.get(0),k=()=>Le.test(location.hash)&&p.isConnected,L=M.observable([]),b=M.observable({available:null,site:null,items:[]}),de=M.observable(!0),Z=M.observable(""),m=M.observable("");let ue="all";try{ue=Ge.includes(S.get(re))?S.get(re):"all"}catch{}const C=M.observable(ue),K=M.observable(""),R=M.observable("all"),pe=M.observable("");h.add(M.effect(()=>{const e=C.value;try{S.set(re,e)}catch{}}));const V=M.computed(()=>new Map((b.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(b.peek().items||[]).find(t=>t.slug===e),ee=M.observable([]);h.add(M.effect(()=>{const e=V.value;ee.value=Ze(L.value,{filter:C.value,query:K.value,latest:e}).map(t=>({...st(t,e),accent:Ne(t.name)}))}));const Re=M.computed(()=>{const e=L.value,t=V.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>nt(n,t)).length}}),_=M.computed(()=>(b.value.items||[]).filter(e=>oe(e)==="update").length),je=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),te=M.observable([]);h.add(M.effect(()=>{te.value=Xe(b.value.items,{filter:R.value,query:pe.value}).map(e=>{const t=et(e),a=t.action==="update"||t.action==="install";return{...t,accent:Ne(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const Fe=M.computed(()=>{const e=b.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>!t.installed&&t.entitled).length,updates:_.value}}),H=e=>L.peek().find(t=>t.name===e);async function ae(){try{const e=await U.plugins.list();if(!k())return;L.value=Array.isArray(e)?e:[],Z.value=""}catch(e){Z.value=`Could not load the plugins: ${e.message||e}`}finally{de.value=!1}}async function be(){try{const e=await U.marketplace.catalogue();if(!k())return;b.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{b.value={available:!1,site:null,items:[],failed:!0}}}const B=async()=>{await Promise.all([ae(),be()]),Ye(ot)};async function ne(e,t){if(e.core)return;const a=t?"on":"off";if(await E.confirm(`Switch ${r(e.displayName||e.name)} ${a}? The server restarts to apply it, which takes a few seconds.`)&&!m.peek()){m.value=`Switching ${e.displayName||e.name} ${a}\u2026`;try{const n=await U.plugins.update(e.name,{enabled:t});await Qe(n,{onStatus:l=>{m.value=l}}),E.toast(`${e.displayName||e.name} is ${a}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(n){E.toast(`Not switched ${a}: ${n.message||n}`,{type:"error"})}finally{m.value="",k()&&await B()}}}async function j(e,{update:t=!1}={}){const a=r(e.displayName||e.slug),n=t?`<p>Update ${a} from ${r(e.installedVersion)} to ${r(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${r(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await U.marketplace.install(e.slug,e.version,{update:t}),v=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${v} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await B()}}async function me(e,t){if(await E.confirm(`<p>Uninstall ${r(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{await U.marketplace.uninstall(e),E.toast(`${t||e} uninstalled. Restart the server to apply.`,{type:"success",duration:6e3})}catch(a){E.toast(`Uninstall failed: ${a.message||a}`,{type:"error"})}k()&&await B()}}async function ve(e){try{await U.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const fe=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},q=p.querySelector(".plx-file");async function De(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await U.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${r(n.name)} ${r(n.installedVersion||"the installed version")} is already installed. Update it to ${r(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${r(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${r(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",k()&&await B()}}const ge=async()=>{const e=q.files?.[0];q.value="",e&&await De(e)};q.addEventListener("change",ge),h.add(()=>q.removeEventListener("change",ge));const Y=new Set;let A=null,he=!1;function ye({title:e,size:t="md",panel:a,model:n,methods:l,dirty:v=()=>!1,form:u=null,cleanup:c=()=>{}}){const i=E.slideover({title:r(e),size:t,position:"right"});i.element.appendChild(a);const $=Te(a),x=M.applyBindings(n,a,{methods:l});u&&(A=u),Y.add(i),He(i,()=>{c(),x.dispose(),$(),Y.delete(i),A===u&&(A=null)});const w=i.close.bind(i);let f=!1,T=!1;return i.close=async F=>{if(!f&&F!==!0&&v()){if(T)return;T=!0;const D=await E.confirm("Discard the changes you have not saved?");if(T=!1,!D)return}return f=!0,w()},I.scan(a),i.open(),i}function se(e,t=null){const a=H(e);if(!a)return;const n=Ue(a),l=Oe(a),v=W(a.name),u=Ae(a,V.peek()),c=!!(u&&v?.entitled),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(d=>d.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>lt(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),T=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${u?` (v${u} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",at(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),D=(s,d)=>{const N=`plx-set-${d}`,Q=s.help?` <span data-help="${r(s.help)}"></span>`:"",z=`<label class="form-label" for="${N}">${r(s.label)}${Q}</label>`,P=`data-model="v${d}.value"`,G=`<small class="plx-err" data-bind-hidden="!err${d}.value" data-bind-text="err${d}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${z}<input id="${N}" type="checkbox" class="form-switch" ${P}></div>`;if(s.type==="select")return`<div>${z}<select id="${N}" class="form-input" ${P}>${s.options.map(Me=>`<option value="${r(Me.value)}">${r(Me.label)}</option>`).join("")}</select>${G}</div>`;if(s.type==="json")return`<div>${z}<textarea id="${N}" class="form-input plx-json" rows="5" spellcheck="false" ${P}></textarea>${G}</div>`;if(s.type==="textarea")return`<div>${z}<textarea id="${N}" class="form-input" rows="3" ${P}></textarea>${G}</div>`;const Ve=s.type==="number"?"number":s.secret?"password":"text",We=`${s.min!=null?` min="${r(s.min)}"`:""}${s.max!=null?` max="${r(s.max)}"`:""}`;return`<div>${z}<input id="${N}" type="${Ve}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${We} ${P}>${G}</div>`},o={saving:f,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,d)=>[[`v${d}`,x[s.key]],[`err${d}`,M.computed(()=>w.value.errors[s.key]||"")]]))},g=ce(`
32
+ </div>`,c=A=>String(A??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ue(A){const b=document.createElement("template");return b.innerHTML=A.trim(),b.content.firstElementChild}const g=et(Be);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(A){g.reset();const b=A.get(0).querySelector(".plx")||A.get(0),$=()=>Be.test(location.hash)&&b.isConnected,U=M.observable([]),q=M.observable([]),pe=M.computed(()=>q.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),v=M.observable({available:null,site:null,items:[]}),me=M.observable(!0),te=M.observable(""),m=M.observable("");let be="all";try{be=at.includes(S.get(de))?S.get(de):"all"}catch{}const T=M.observable(be),W=M.observable(""),R=M.observable("all"),ve=M.observable("");g.add(M.effect(()=>{const e=T.value;try{S.set(de,e)}catch{}}));const _=M.computed(()=>new Map((v.value.items||[]).map(e=>[e.slug,e.version]))),H=e=>(v.peek().items||[]).find(t=>t.slug===e),ae=M.observable([]);g.add(M.effect(()=>{const e=_.value;ae.value=st(U.value,{filter:T.value,query:W.value,latest:e}).map(t=>({...dt(t,e),accent:Le(t.name)}))}));const Fe=M.computed(()=>{const e=U.value,t=_.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>rt(n,t)).length}}),Y=M.computed(()=>(v.value.items||[]).filter(e=>ce(e)==="update").length),De=M.computed(()=>U.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),ne=M.observable([]);g.add(M.effect(()=>{ne.value=nt(v.value.items,{filter:R.value,query:ve.value}).map(e=>{const t=lt(e),a=t.action==="update"||t.action==="install";return{...t,accent:Le(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const qe=M.computed(()=>{const e=v.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>!t.installed&&t.entitled).length,updates:Y.value}}),J=e=>U.peek().find(t=>t.name===e);async function se(){try{const[e,t]=await Promise.all([k.plugins.list(),k.tools.list().catch(()=>[])]);if(!$())return;U.value=Array.isArray(e)?e:[],q.value=Array.isArray(t)?t:[],te.value=""}catch(e){te.value=`Could not load the plugins: ${e.message||e}`}finally{me.value=!1}}async function fe(){try{const e=await k.marketplace.catalogue();if(!$())return;v.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{v.value={available:!1,site:null,items:[],failed:!0}}}const z=async()=>{await Promise.all([se(),fe()]),Ze(mt)};async function j(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${c(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||m.peek())return;const l=o=>a==="tool"?k.tools.update(e.name,{enabled:t,cascade:o}):k.plugins.update(e.name,{enabled:t,cascade:o});m.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let o;try{o=await l(!1)}catch(d){if(d.status!==409||!d.body?.needsConfirm)throw d;const u=ct(d.body.plan);if(m.value="",!await E.confirm(`<p>Switching ${c(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(i=>`<li>${c(i)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;m.value=`Switching ${e.displayName||e.name} ${n}\u2026`,o=await l(!0)}await tt(o,{onStatus:d=>{m.value=d}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(o){E.toast(`Not switched ${n}: ${o.message||o}`,{type:"error"})}finally{m.value="",$()&&await z()}}async function B(e,{update:t=!1}={}){const a=c(e.displayName||e.slug),n=t?`<p>Update ${a} from ${c(e.installedVersion)} to ${c(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${c(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await k.marketplace.install(e.slug,e.version,{update:t}),o=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${o} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}$()&&await z()}}async function he(e,t){if(await E.confirm(`<p>Uninstall ${c(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{await k.marketplace.uninstall(e),E.toast(`${t||e} uninstalled. Restart the server to apply.`,{type:"success",duration:6e3})}catch(a){E.toast(`Uninstall failed: ${a.message||a}`,{type:"error"})}$()&&await z()}}async function ge(e){try{await k.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ye=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},P=b.querySelector(".plx-file");async function ze(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await k.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${c(n.name)} ${c(n.installedVersion||"the installed version")} is already installed. Update it to ${c(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${c(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${c(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",$()&&await z()}}const xe=async()=>{const e=P.files?.[0];P.value="",e&&await ze(e)};P.addEventListener("change",xe),g.add(()=>P.removeEventListener("change",xe));const Q=new Set;let L=null,we=!1;function ke({title:e,size:t="md",panel:a,model:n,methods:l,dirty:o=()=>!1,form:d=null,cleanup:u=()=>{}}){const i=E.slideover({title:c(e),size:t,position:"right"});i.element.appendChild(a);const C=Oe(a),x=M.applyBindings(n,a,{methods:l});d&&(L=d),Q.add(i),Xe(i,()=>{u(),x.dispose(),C(),Q.delete(i),L===d&&(L=null)});const w=i.close.bind(i);let f=!1,N=!1;return i.close=async F=>{if(!f&&F!==!0&&o()){if(N)return;N=!0;const D=await E.confirm("Discard the changes you have not saved?");if(N=!1,!D)return}return f=!0,w()},I.scan(a),i.open(),i}function le(e,t=null){const a=J(e);if(!a)return;const n=Ue(a),l=Re(a),o=H(a.name),d=je(a,_.peek()),u=!!(d&&o?.entitled),C={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable(C[s.key])])),w=M.computed(()=>ut(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),N=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${d?` (v${d} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",ot(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),D=(s,p)=>{const O=`plx-set-${p}`,X=s.help?` <span data-help="${c(s.help)}"></span>`:"",K=`<label class="form-label" for="${O}">${c(s.label)}${X}</label>`,V=`data-model="v${p}.value"`,Z=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${K}<input id="${O}" type="checkbox" class="form-switch" ${V}></div>`;if(s.type==="select")return`<div>${K}<select id="${O}" class="form-input" ${V}>${s.options.map(Ne=>`<option value="${c(Ne.value)}">${c(Ne.label)}</option>`).join("")}</select>${Z}</div>`;if(s.type==="json")return`<div>${K}<textarea id="${O}" class="form-input plx-json" rows="5" spellcheck="false" ${V}></textarea>${Z}</div>`;if(s.type==="textarea")return`<div>${K}<textarea id="${O}" class="form-input" rows="3" ${V}></textarea>${Z}</div>`;const Je=s.type==="number"?"number":s.secret?"password":"text",Qe=`${s.min!=null?` min="${c(s.min)}"`:""}${s.max!=null?` max="${c(s.max)}"`:""}`;return`<div>${K}<input id="${O}" type="${Je}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Qe} ${V}>${Z}</div>`},r={saving:f,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,x[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},h=ue(`
23
33
  <div class="plx-form">
24
34
  <p class="plx-desc"></p>
25
35
  <dl class="plx-facts"></dl>
@@ -31,7 +41,7 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
31
41
  <p class="plx-hint" data-if="!hasFields">This plugin has no settings to change here.</p>
32
42
  <div class="plx-actions">
33
43
  ${a.core?"":`<button type="button" class="btn btn-sm btn-ghost" data-on-click="toggle"><span data-icon="${a.enabled?"pause":"play"}" data-icon-size="13"></span> ${a.enabled?"Switch off":"Switch on"}</button>`}
34
- ${c?`<button type="button" class="btn btn-sm btn-ghost" data-on-click="update"><span data-icon="download" data-icon-size="13"></span> Update to v${r(u)}</button>`:""}
44
+ ${u?`<button type="button" class="btn btn-sm btn-ghost" data-on-click="update"><span data-icon="download" data-icon-size="13"></span> Update to v${c(d)}</button>`:""}
35
45
  ${a.sourceEditable!==!1?'<button type="button" class="btn btn-sm btn-ghost" data-on-click="source"><span data-icon="code" data-icon-size="13"></span> Source</button>':""}
36
46
  ${a.closedSource?"":'<button type="button" class="btn btn-sm btn-ghost" data-on-click="exportIt" title="Download as a .dcmsplugin file"><span data-icon="download" data-icon-size="13"></span> Export</button>'}
37
47
  <span class="plx-spacer"></span>
@@ -39,7 +49,7 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
39
49
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
40
50
  <span data-icon="check" data-icon-size="13"></span> Save settings</button>
41
51
  </div>
42
- </div>`);g.querySelector(".plx-desc").textContent=a.description||"";const Ke=g.querySelector(".plx-facts");for(const[s,d]of F){const N=document.createElement("dt");N.textContent=s;const Q=document.createElement("dd");Q.textContent=d,Ke.append(N,Q)}async function Se(){if(o.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await U.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),J.close(!0),k()&&await ae()}catch(d){E.toast(`Not saved: ${d.message||d}`,{type:"error"})}finally{f.value=!1}}const ie=s=>async()=>{await J.close(),Y.has(J)||s()},J=ye({title:a.displayName||a.name,size:"md",panel:g,model:o,dirty:T,form:l.length?{kind:"settings",name:a.name,dirty:T,values:()=>{const s={};for(const d of l)d.secret||(s[d.key]=x[d.key].peek());return s}}:null,methods:{save:Se,cancel:()=>J.close(),toggle:ie(()=>ne(a,!a.enabled)),update:ie(()=>j(v,{update:!0})),source:ie(()=>fe(a)),exportIt:()=>ve(a)}});g.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Se())})}function xe(e=null){const t=_e(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([o,g])=>[o,g.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([o,g])=>[o,g.peek()])),v=()=>JSON.stringify(l())!==n||!!e;let u=!!e?.slug;const c=M.effect(()=>{const o=a.displayName.value;u||(a.slug.value=it(o))}),i=M.observable(!1),$=M.computed(()=>tt(Object.fromEntries(Object.entries(a).map(([o,g])=>[o,g.value])),L.value.map(o=>o.name))),x=o=>M.computed(()=>$.value.errors[o]||""),w={...a,saving:i,nameErr:x("displayName"),slugErr:x("slug"),descErr:x("description"),authorErr:x("author"),iconErr:x("icon"),cannot:M.computed(()=>i.value||Object.keys($.value.errors).length>0)},f=ce(`
52
+ </div>`);h.querySelector(".plx-desc").textContent=a.description||"";const Ye=h.querySelector(".plx-facts");for(const[s,p]of F){const O=document.createElement("dt");O.textContent=s;const X=document.createElement("dd");X.textContent=p,Ye.append(O,X)}async function Te(){if(r.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await k.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),G.close(!0),$()&&await se()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const re=s=>async()=>{await G.close(),Q.has(G)||s()},G=ke({title:a.displayName||a.name,size:"md",panel:h,model:r,dirty:N,form:l.length?{kind:"settings",name:a.name,dirty:N,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=x[p.key].peek());return s}}:null,methods:{save:Te,cancel:()=>G.close(),toggle:re(()=>j(a,!a.enabled)),update:re(()=>B(o,{update:!0})),source:re(()=>ye(a)),exportIt:()=>ge(a)}});h.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Te())})}function $e(e=null){const t=Ge(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([r,h])=>[r,h.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([r,h])=>[r,h.peek()])),o=()=>JSON.stringify(l())!==n||!!e;let d=!!e?.slug;const u=M.effect(()=>{const r=a.displayName.value;d||(a.slug.value=pt(r))}),i=M.observable(!1),C=M.computed(()=>it(Object.fromEntries(Object.entries(a).map(([r,h])=>[r,h.value])),U.value.map(r=>r.name))),x=r=>M.computed(()=>C.value.errors[r]||""),w={...a,saving:i,nameErr:x("displayName"),slugErr:x("slug"),descErr:x("description"),authorErr:x("author"),iconErr:x("icon"),cannot:M.computed(()=>i.value||Object.keys(C.value.errors).length>0)},f=ue(`
43
53
  <div class="plx-form">
44
54
  <div>
45
55
  <label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
@@ -75,7 +85,7 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
75
85
  <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
76
86
  <span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
77
87
  </div>
78
- </div>`);async function T(){if(w.cannot.peek())return;i.value=!0;const{payload:o}=$.peek();try{await U.plugins.scaffold(o),E.toast(`"${o.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await B()}catch(g){E.toast(g.message||"Could not create the plugin.",{type:"error"})}finally{i.value=!1}}const F=ye({title:"New plugin",size:"sm",panel:f,model:w,dirty:v,form:{kind:"new",name:null,dirty:v,values:l},cleanup:()=>c(),methods:{create:T,cancel:()=>F.close()}}),D=f.querySelector("#plx-np-slug");D.addEventListener("input",()=>{u=!!D.value}),f.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),T())}),f.querySelector("#plx-np-name")?.focus()}function Be(e){const t=H(e);if(!t)return[];const a=W(t.name),n=Ae(t,V.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Oe(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>se(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>ne(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>ne(t,!0)}],...n?[{label:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled,action:()=>j(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>fe(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ve(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>me(t.name,t.displayName)}]]}function qe(e){const t=W(e);if(!t)return[];const a=oe(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>j(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>j(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{K.value=t.slug,C.value="all",$e.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>me(t.slug,t.displayName)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[]]}function ze(e){const t=W(e);if(!t)return;const a=oe(t);a==="install"?j(t):a==="update"?j(t,{update:!0}):t.installed&&(K.value=t.slug,C.value="all",$e.activate?.(0))}function we(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:c=>a(c.dataset[t])});h.add(()=>l?.destroy?.());const v=c=>{const i=c.target.closest(".plx-row");if(!i)return;const $=c.target.closest(".plx-kebab");if($){Ie($);return}if(c.target.closest(".plx-act")){c.target.closest("button").disabled||n(i.dataset[t]);return}n(i.dataset[t])},u=c=>{const i=c.target.closest(".plx-row");i&&(c.key==="F10"&&c.shiftKey?(c.preventDefault(),Ie(i)):c.key==="Enter"&&c.target===i&&n(i.dataset[t]))};e.addEventListener("click",v),e.addEventListener("keydown",u),h.add(()=>{e.removeEventListener("click",v),e.removeEventListener("keydown",u)})}const le=p.querySelector(".plx-browse");le.appendChild(ce(`
88
+ </div>`);async function N(){if(w.cannot.peek())return;i.value=!0;const{payload:r}=C.peek();try{await k.plugins.scaffold(r),E.toast(`"${r.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),$()&&await z()}catch(h){E.toast(h.message||"Could not create the plugin.",{type:"error"})}finally{i.value=!1}}const F=ke({title:"New plugin",size:"sm",panel:f,model:w,dirty:o,form:{kind:"new",name:null,dirty:o,values:l},cleanup:()=>u(),methods:{create:N,cancel:()=>F.close()}}),D=f.querySelector("#plx-np-slug");D.addEventListener("input",()=>{d=!!D.value}),f.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),N())}),f.querySelector("#plx-np-name")?.focus()}function Pe(e){const t=J(e);if(!t)return[];const a=H(t.name),n=je(t,_.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Re(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>le(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>j(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>j(t,!0)}],...n?[{label:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled,action:()=>B(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>ye(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ge(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>he(t.name,t.displayName)}]]}function Ke(e){const t=q.peek().find(n=>n.name===e);if(!t)return[];const a=!!m.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>j(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>j(t,!0,"tool")}]}const Ve=e=>{const t=q.peek().find(a=>a.name===e);t&&j(t,!t.enabled,"tool")};function We(e){const t=H(e);if(!t)return[];const a=ce(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{W.value=t.slug,T.value="all",Me.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>he(t.slug,t.displayName)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[]]}function _e(e){const t=H(e);if(!t)return;const a=ce(t);a==="install"?B(t):a==="update"?B(t,{update:!0}):t.installed&&(W.value=t.slug,T.value="all",Me.activate?.(0))}function ie(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});g.add(()=>l?.destroy?.());const o=u=>{const i=u.target.closest(".plx-row");if(!i)return;const C=u.target.closest(".plx-kebab");if(C){Ae(C);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(i.dataset[t]);return}n(i.dataset[t])},d=u=>{const i=u.target.closest(".plx-row");i&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ae(i)):u.key==="Enter"&&u.target===i&&n(i.dataset[t]))};e.addEventListener("click",o),e.addEventListener("keydown",d),g.add(()=>{e.removeEventListener("click",o),e.removeEventListener("keydown",d)})}const oe=b.querySelector(".plx-browse");oe.appendChild(ue(`
79
89
  <div class="plx-card">
80
90
  <p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
81
91
  <p class="plx-note" data-bind-hidden="browseState.value !== 'error'">The catalogue could not be loaded - it needs an administrator, and a manager that is reachable.</p>
@@ -98,4 +108,4 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
98
108
  <p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
99
109
  <div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
100
110
  </div>
101
- </div>`)),le.querySelector(".plx-catalogue").innerHTML=ct;const ke=p.querySelector(".plx-installed");ke.innerHTML=rt;const $e=E.tabs(p.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Pe=M.applyBindings({plugins:L,catalogue:b,loading:de,loadError:Z,restarting:m,filter:C,query:K,rows:ee,counts:Re,emptyText:je,updateCount:_,updateCountText:M.computed(()=>`${_.value} update${_.value===1?"":"s"}`),browseFilter:R,browseQuery:pe,browseRows:te,browseCounts:Fe,siteText:M.computed(()=>b.value.site?`Licensed to ${b.value.site}.`:""),browseState:M.computed(()=>b.value.available===null?"loading":b.value.failed?"error":b.value.available?"list":"file")},p,{methods:{showAll:()=>{C.value="all"},showOn:()=>{C.value="on"},showOff:()=>{C.value="off"},showAttention:()=>{C.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});h.add(()=>Pe.dispose()),h.add(Te(p,{position:"bottom"})),h.add(M.effect(()=>{ee.value,te.value,requestAnimationFrame(()=>{p.isConnected&&I.scan(p)})})),we(ke,"name",Be,e=>se(e)),we(le.querySelector(".plx-catalogue"),"slug",qe,ze),Ce(O,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>q.click()}),Ce(O,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>xe()});const Ee=e=>{(m.peek()||A?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ee),h.add(()=>window.removeEventListener("beforeunload",Ee)),h.add(()=>{if(A?.dirty())try{S.set(X,{kind:A.kind,name:A.name,values:A.values(),at:new Date().toISOString()})}catch{}he=!0,[...Y].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(p),await Promise.all([ae(),be()]),!k())return;let y=null;try{y=S.get(X)}catch{}if(y?.values&&!he){try{S.remove?S.remove(X):S.set(X,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${r(H(y.name)?.displayName||y.name)}`;(y.kind==="new"||H(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(y.kind==="new"?xe(y.values):se(y.name,y.values))}}};
111
+ </div>`)),oe.querySelector(".plx-catalogue").innerHTML=ft;const Ee=b.querySelector(".plx-installed");Ee.innerHTML=bt;const Se=b.querySelector(".plx-tools");Se.innerHTML=vt;const Me=E.tabs(b.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),He=M.applyBindings({plugins:U,tools:q,toolRows:pe,catalogue:v,loading:me,loadError:te,restarting:m,filter:T,query:W,rows:ae,counts:Fe,emptyText:De,updateCount:Y,updateCountText:M.computed(()=>`${Y.value} update${Y.value===1?"":"s"}`),browseFilter:R,browseQuery:ve,browseRows:ne,browseCounts:qe,siteText:M.computed(()=>v.value.site?`Licensed to ${v.value.site}.`:""),browseState:M.computed(()=>v.value.available===null?"loading":v.value.failed?"error":v.value.available?"list":"file")},b,{methods:{showAll:()=>{T.value="all"},showOn:()=>{T.value="on"},showOff:()=>{T.value="off"},showAttention:()=>{T.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});g.add(()=>He.dispose()),g.add(Oe(b,{position:"bottom"})),g.add(M.effect(()=>{ae.value,pe.value,ne.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})})),ie(Ee,"name",Pe,e=>le(e)),ie(Se,"tool",Ke,Ve),ie(oe.querySelector(".plx-catalogue"),"slug",We,_e),Ie(A,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>P.click()}),Ie(A,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>$e()});const Ce=e=>{(m.peek()||L?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ce),g.add(()=>window.removeEventListener("beforeunload",Ce)),g.add(()=>{if(L?.dirty())try{S.set(ee,{kind:L.kind,name:L.name,values:L.values(),at:new Date().toISOString()})}catch{}we=!0,[...Q].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(b),await Promise.all([se(),fe()]),!$())return;let y=null;try{y=S.get(ee)}catch{}if(y?.values&&!we){try{S.remove?S.remove(ee):S.set(ee,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${c(J(y.name)?.displayName||y.name)}`;(y.kind==="new"||J(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&(y.kind==="new"?$e(y.values):le(y.name,y.values))}}};
@@ -1,4 +1,4 @@
1
- import{api as G,apiRequest as w}from"../api.js";import{placeTools as Re}from"../lib/sidebar-grouping.js";import{addBannerAction as fe}from"../lib/plugin-chrome.js";import{helpPopovers as Ae}from"../lib/help-popover.js";import{openMenuAt as Q,sidebarCount as he}from"../lib/tool-kit.js";import{changedTabs as He,describeCacheKey as X,draftOf as Pe,expiresIn as $e,formFromSettings as De,homeValue as je,payloadFromForm as Ke,settingsProblems as _e,snapshot as ve,TAB_FIELDS as Ie}from"../lib/site-settings-arrange.js";const Z=/^\/settings(\?.*)?$/,be="#/settings",T="settings.draft",Be=`
1
+ import{api as Q,apiRequest as w}from"../api.js";import{placeTools as Pe}from"../lib/sidebar-grouping.js";import{addBannerAction as he}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{openMenuAt as X,sidebarCount as ve}from"../lib/tool-kit.js";import{changedTabs as Ae,describeCacheKey as Z,draftOf as He,expiresIn as De,formFromSettings as je,homeValue as Ke,payloadFromForm as _e,siteUrlError as be,settingsProblems as Ie,snapshot as ye,TAB_FIELDS as Be}from"../lib/site-settings-arrange.js";const ee=/^\/settings(\?.*)?$/,ge="#/settings",T="settings.draft",Ue=`
2
2
  <div class="stx-problem">
3
3
  <span class="stx-chip" data-bind-class="tone === 'danger' ? 'is-bad' : 'is-warn'">{{label}}</span>
4
4
  <span>{{text}}</span>
@@ -8,6 +8,6 @@ import{api as G,apiRequest as w}from"../api.js";import{placeTools as Re}from"../
8
8
  <span class="stx-main"><strong>{{path}}</strong><small>{{meta}}</small></span>
9
9
  <span class="stx-chip">{{expires}}</span>
10
10
  <button type="button" class="stx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
11
- </div>`,Fe=["general","seo","footer","email","backToTop","cookies","breadcrumbs","css","cache"];async function ze(l,p){const[r,O]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),a=new Map([["/","Dashboard (built in)"]]),u=(C,m)=>{for(const o of C||[]){if(!o||o.hidden||o.restricted)continue;const f=typeof o.url=="string"?o.url:"";f.startsWith("#/")&&f.length>2&&!a.has(f.slice(1))&&a.set(f.slice(1),`${m?`${m} \u203A `:""}${o.text||f}`),u(o.items,o.url?m:o.text||m)}};u(Re(r?.items||[],O||[]),""),a.has(p)||a.set(p,`${p} (not in the sidebar)`),l.replaceChildren();for(const[C,m]of a){const o=document.createElement("option");o.value=C,o.textContent=m,l.appendChild(o)}}let y=null,ye=!1;function Ne(){ye||!window.R?.use||(ye=!0,R.use((l,p,r)=>!(Z.test(p?.path||"")&&!Z.test(l?.path||""))||!y?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(a=>{a?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(y&&!Z.test(l?.path||"")){const p=y;y=null,p.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Ne(),y){const e=y;y=null,e.dispose()}const p=[],r=e=>(p.push(e),e);let O=!1;const a=l.get(0).querySelector(".stx")||l.get(0),u=()=>!O&&a.isConnected,C=E.loader(l.get(0),{type:"dots"});let m,o;try{[m,o]=await Promise.all([G.settings.get(),w("/settings/custom-css").then(e=>e?.css||"").catch(()=>null)])}catch(e){C.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(C.destroy(),!a.isConnected)return;const f=o!==null,ee=M.observable(m||{}),te=De(m||{},o||""),n={};for(const[e,t]of Object.entries(te))n[e]=M.observable(t);const B=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),se=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),g=M.observable(te),q=M.computed(()=>ve(g.value)),L=M.computed(()=>ve(B())!==q.value),A=M.observable(!1),P=M.observable(""),F=M.observable(!1),ne=M.computed(()=>{const e=new Set(He(B(),g.value));return Object.fromEntries(Object.keys(Ie).map(t=>[t,e.has(t)]))}),z=M.observable([]);r(M.effect(()=>{z.value=_e(B()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const ae=M.observable(""),$=M.observable(!1),j=M.observable(""),K=M.observable(""),h=M.observable(null),N=M.observable([]),V=M.observable(""),U=M.observable(!0),W=M.observable(""),oe=M.observable([]);r(M.effect(()=>{const e=V.value.trim().toLowerCase(),t=Date.now();oe.value=[...N.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(i=>String(i).toLowerCase().includes(e))).map(s=>{const i=X(s.key);return{id:s.key,path:i.path,meta:[i.page?`as ${i.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:$e(s.expiresAt,t)}})}));const ge={...n,saving:A,dirty:L,tabs:ne,problems:z,testTo:ae,testing:$,testResult:j,testTone:K,cacheQuery:V,cacheLoading:U,cacheError:W,cacheRows:oe,errorText:P,problemsText:M.computed(()=>z.value.length?"yes":""),noSmtp:M.computed(()=>!String(n.smtpHost.value||"").trim()),dirtyEmail:M.computed(()=>ne.value.email),seoLength:M.computed(()=>{const e=String(n.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cssSize:M.computed(()=>{if(!f)return"Could not load content/custom.css - reload to try again. It will not be saved until it loads.";const e=new Blob([String(n.css.value||"")]).size/1024;return`${e.toFixed(1)} KB of 100 KB${e>100?" - too big to save":""}`}),cacheOn:M.computed(()=>h.value?.enabled===!0),cacheState:M.computed(()=>{const e=h.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=h.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=h.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>h.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>V.value.trim()?"Nothing matches.":h.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function Y(){if(A.peek())return;const e=se(),t=g.peek(),s=String(e.css)!==String(t.css);if(s&&!f){E.toast("The custom CSS did not load, so it cannot be saved - reload the screen first.",{type:"error"});return}A.value=!0,P.value="";const i=String(e.title).trim()!==String(t.title).trim();try{await G.settings.save(Ke(e,ee.peek(),{adminHomeReady:F.peek()})),s&&await w("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:String(e.css)})});const c=await G.settings.get().catch(()=>null);if(!u())return;c&&(ee.value=c);const b={...e,smtpPass:""};n.smtpPass.value="",g.value=b;try{S.remove?S.remove(T):S.set(T,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),i&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),he(be),E.toast("Settings saved.",{type:"success"})}catch(c){P.value=c.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{A.value=!1}}async function ke(){await E.confirm("Throw away your unsaved settings?")&&(re(g.peek()),P.value="")}function re(e){for(const[t,s]of Object.entries(e))n[t]&&(n[t].value=s);d&&d.getValue()!==String(n.css.peek())?d.setValue(String(n.css.peek())):!d&&v&&(v.value=String(n.css.peek()))}async function we(){if(!$.peek()){$.value=!0,K.value="",j.value="Sending\u2026";try{const e=await w("/settings/test-email",{method:"POST",body:JSON.stringify({to:ae.peek().trim()||void 0})});if(!u())return;j.value=e?.message||"Test email sent.",K.value="is-ok"}catch(e){if(!u())return;j.value=e.message||"Could not send the test email.",K.value="is-bad"}finally{$.value=!1}}}async function _(){U.value=!0;try{const[e,t]=await Promise.all([w("/cache/status").catch(()=>null),w("/cache/keys")]);if(!u())return;h.value=e,N.value=Array.isArray(t?.entries)?t.entries:[],W.value=""}catch(e){if(!u())return;W.value=`Could not read the cache: ${e.message||e}`}finally{U.value=!1}}async function Ee(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await w("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}u()&&await _()}}async function Se(){const e=h.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{h.value=await w("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),he(be)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}u()&&await _()}}const ie=e=>N.peek().find(t=>t.key===e);function Me(e){const t=ie(e);if(!t)return[];const s=X(t.key);return[{header:s.path},{label:"Open the page",icon:"external-link",disabled:!s.page,action:()=>window.open(s.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...s.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(s.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}a.querySelector(".stx-problems").innerHTML=Be;const x=a.querySelector(".stx-list");x.innerHTML=qe;const Ce=E.tabs(a.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),xe=M.applyBindings(ge,a,{methods:{save:Y,discard:ke,sendTest:we,refreshCache:_,clearCache:Ee,toggleCache:Se,corner:e=>{n.bcPosition.value=e}}});r(()=>xe.dispose()),r(Ae(a,{position:"bottom"}));const Te=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Fe.indexOf(t.dataset.go);s>=0&&Ce.activate(s)};a.querySelector(".stx-problems").addEventListener("click",Te);const J=a.querySelector("#stx-admin-home");ze(J,je(n.adminHome.peek())).then(()=>{u()&&(J.value=n.adminHome.peek(),F.value=!0)}).catch(()=>{});const v=a.querySelector(".stx-css");v.value=String(n.css.peek());let d=null;if(!f)v.disabled=!0;else if(E.editor)try{d=E.editor(v,{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0,onChange:e=>{n.css.value=e}})}catch{d=null}if(d)d._editorEl&&d._editorEl.addEventListener("keydown",e=>{if(!(e.ctrlKey||e.metaKey)||e.key!=="c"&&e.key!=="x")return;const t=d._editorEl;if(t.selectionStart!==t.selectionEnd||typeof t.value!="string")return;e.preventDefault();const s=t.value,i=t.selectionStart,c=s.lastIndexOf(`
12
- `,i-1)+1,b=s.indexOf(`
13
- `,i),Le=b===-1?s.slice(c):s.slice(c,b+1);navigator.clipboard?.writeText(Le).catch(()=>{}),e.key==="x"?(t.value=s.slice(0,c)+(b===-1?"":s.slice(b+1)),t.selectionStart=t.selectionEnd=c,t.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})):E.toast("Line copied",{type:"info",duration:1500})});else{const e=()=>{n.css.value=v.value};v.addEventListener("input",e),r(()=>v.removeEventListener("input",e))}r(()=>{try{d?.destroy?.()}catch{}});const Oe=E.contextMenu(x,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Me(e.dataset.id)});r(()=>Oe?.destroy?.());const ce=e=>{const t=ie(e.dataset.id),s=t&&X(t.key);s?.page?window.open(s.path,"_blank","noopener"):Q(e)},le=e=>{const t=e.target.closest(".stx-row");if(!t)return;const s=e.target.closest(".stx-kebab");if(s){Q(s);return}ce(t)},ue=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Q(t)):e.key==="Enter"&&e.target===t&&ce(t))};x.addEventListener("click",le),x.addEventListener("keydown",ue),r(()=>{x.removeEventListener("click",le),x.removeEventListener("keydown",ue)}),fe(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),fe(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>Y()});const de=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),Y())};document.addEventListener("keydown",de,!0),r(()=>document.removeEventListener("keydown",de,!0));const pe=()=>{if(L.peek())try{S.set(T,{form:Pe(se()),base:q.peek(),at:new Date().toISOString()})}catch{}},me=e=>{L.peek()&&(pe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",me),r(()=>window.removeEventListener("beforeunload",me)),y={dirty:()=>L.peek(),dispose(){O=!0,pe(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a),_();let k=null;try{k=S.get(T)}catch{}if(k?.form){try{S.remove?S.remove(T):S.set(T,null)}catch{}const e=k.base&&k.base!==q.peek(),t=k.at?` (${D(k.at).format("D MMM HH:mm")})`:"",s=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(s)&&u()){const i={...g.peek()};for(const[c,b]of Object.entries(k.form))c in i&&c!=="smtpPass"&&(i[c]=b);f||(i.css=g.peek().css),re(i),F.peek()&&(J.value=n.adminHome.peek())}}}};
11
+ </div>`,Fe=["general","seo","footer","email","backToTop","cookies","breadcrumbs","css","cache"];async function ze(c,p){const[i,O]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),a=new Map([["/","Dashboard (built in)"]]),l=(C,m)=>{for(const o of C||[]){if(!o||o.hidden||o.restricted)continue;const f=typeof o.url=="string"?o.url:"";f.startsWith("#/")&&f.length>2&&!a.has(f.slice(1))&&a.set(f.slice(1),`${m?`${m} \u203A `:""}${o.text||f}`),l(o.items,o.url?m:o.text||m)}};l(Pe(i?.items||[],O||[]),""),a.has(p)||a.set(p,`${p} (not in the sidebar)`),c.replaceChildren();for(const[C,m]of a){const o=document.createElement("option");o.value=C,o.textContent=m,c.appendChild(o)}}let y=null,ke=!1;function Ne(){ke||!window.R?.use||(ke=!0,R.use((c,p,i)=>!(ee.test(p?.path||"")&&!ee.test(c?.path||""))||!y?.dirty()?i():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(a=>{a?i():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:c}={})=>{if(y&&!ee.test(c?.path||"")){const p=y;y=null,p.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(c){if(Ne(),y){const e=y;y=null,e.dispose()}const p=[],i=e=>(p.push(e),e);let O=!1;const a=c.get(0).querySelector(".stx")||c.get(0),l=()=>!O&&a.isConnected,C=E.loader(c.get(0),{type:"dots"});let m,o;try{[m,o]=await Promise.all([Q.settings.get(),w("/settings/custom-css").then(e=>e?.css||"").catch(()=>null)])}catch(e){C.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(C.destroy(),!a.isConnected)return;const f=o!==null,te=M.observable(m||{}),se=je(m||{},o||""),n={};for(const[e,t]of Object.entries(se))n[e]=M.observable(t);const B=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),ne=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),g=M.observable(se),U=M.computed(()=>ye(g.value)),L=M.computed(()=>ye(B())!==U.value),P=M.observable(!1),$=M.observable(""),q=M.observable(!1),ae=M.computed(()=>{const e=new Set(Ae(B(),g.value));return Object.fromEntries(Object.keys(Be).map(t=>[t,e.has(t)]))}),F=M.observable([]);i(M.effect(()=>{F.value=Ie(B()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const oe=M.observable(""),A=M.observable(!1),j=M.observable(""),K=M.observable(""),v=M.observable(null),z=M.observable([]),N=M.observable(""),V=M.observable(!0),W=M.observable(""),re=M.observable([]);i(M.effect(()=>{const e=N.value.trim().toLowerCase(),t=Date.now();re.value=[...z.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(r=>String(r).toLowerCase().includes(e))).map(s=>{const r=Z(s.key);return{id:s.key,path:r.path,meta:[r.page?`as ${r.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:De(s.expiresAt,t)}})}));const we={...n,saving:P,dirty:L,tabs:ae,problems:F,testTo:oe,testing:A,testResult:j,testTone:K,cacheQuery:N,cacheLoading:V,cacheError:W,cacheRows:re,errorText:$,problemsText:M.computed(()=>F.value.length?"yes":""),siteUrlError:M.computed(()=>be(n.siteUrl.value)),noSmtp:M.computed(()=>!String(n.smtpHost.value||"").trim()),dirtyEmail:M.computed(()=>ae.value.email),seoLength:M.computed(()=>{const e=String(n.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cssSize:M.computed(()=>{if(!f)return"Could not load content/custom.css - reload to try again. It will not be saved until it loads.";const e=new Blob([String(n.css.value||"")]).size/1024;return`${e.toFixed(1)} KB of 100 KB${e>100?" - too big to save":""}`}),cacheOn:M.computed(()=>v.value?.enabled===!0),cacheState:M.computed(()=>{const e=v.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=v.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=v.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>v.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>N.value.trim()?"Nothing matches.":v.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function Y(){if(P.peek())return;const e=ne(),t=g.peek(),s=String(e.css)!==String(t.css);if(s&&!f){E.toast("The custom CSS did not load, so it cannot be saved - reload the screen first.",{type:"error"});return}const r=be(e.siteUrl);if(r){E.toast(`Site URL: ${r}`,{type:"error"});return}P.value=!0,$.value="";const h=String(e.title).trim()!==String(t.title).trim();try{await Q.settings.save(_e(e,te.peek(),{adminHomeReady:q.peek()})),s&&await w("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:String(e.css)})});const d=await Q.settings.get().catch(()=>null);if(!l())return;d&&(te.value=d);const G={...e,smtpPass:""};n.smtpPass.value="",g.value=G;try{S.remove?S.remove(T):S.set(T,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),h&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),ve(ge),E.toast("Settings saved.",{type:"success"})}catch(d){$.value=d.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{P.value=!1}}async function Ee(){await E.confirm("Throw away your unsaved settings?")&&(ie(g.peek()),$.value="")}function ie(e){for(const[t,s]of Object.entries(e))n[t]&&(n[t].value=s);u&&u.getValue()!==String(n.css.peek())?u.setValue(String(n.css.peek())):!u&&b&&(b.value=String(n.css.peek()))}async function Se(){if(!A.peek()){A.value=!0,K.value="",j.value="Sending\u2026";try{const e=await w("/settings/test-email",{method:"POST",body:JSON.stringify({to:oe.peek().trim()||void 0})});if(!l())return;j.value=e?.message||"Test email sent.",K.value="is-ok"}catch(e){if(!l())return;j.value=e.message||"Could not send the test email.",K.value="is-bad"}finally{A.value=!1}}}async function _(){V.value=!0;try{const[e,t]=await Promise.all([w("/cache/status").catch(()=>null),w("/cache/keys")]);if(!l())return;v.value=e,z.value=Array.isArray(t?.entries)?t.entries:[],W.value=""}catch(e){if(!l())return;W.value=`Could not read the cache: ${e.message||e}`}finally{V.value=!1}}async function Me(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await w("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}l()&&await _()}}async function Ce(){const e=v.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{v.value=await w("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),ve(ge)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}l()&&await _()}}const ce=e=>z.peek().find(t=>t.key===e);function xe(e){const t=ce(e);if(!t)return[];const s=Z(t.key);return[{header:s.path},{label:"Open the page",icon:"external-link",disabled:!s.page,action:()=>window.open(s.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...s.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(s.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}a.querySelector(".stx-problems").innerHTML=Ue;const x=a.querySelector(".stx-list");x.innerHTML=qe;const Te=E.tabs(a.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Oe=M.applyBindings(we,a,{methods:{save:Y,discard:Ee,sendTest:Se,refreshCache:_,clearCache:Me,toggleCache:Ce,corner:e=>{n.bcPosition.value=e}}});i(()=>Oe.dispose()),i($e(a,{position:"bottom"}));const Le=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Fe.indexOf(t.dataset.go);s>=0&&Te.activate(s)};a.querySelector(".stx-problems").addEventListener("click",Le);const J=a.querySelector("#stx-admin-home");ze(J,Ke(n.adminHome.peek())).then(()=>{l()&&(J.value=n.adminHome.peek(),q.value=!0)}).catch(()=>{});const b=a.querySelector(".stx-css");b.value=String(n.css.peek());let u=null;if(!f)b.disabled=!0;else if(E.editor)try{u=E.editor(b,{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0,onChange:e=>{n.css.value=e}})}catch{u=null}if(u)u._editorEl&&u._editorEl.addEventListener("keydown",e=>{if(!(e.ctrlKey||e.metaKey)||e.key!=="c"&&e.key!=="x")return;const t=u._editorEl;if(t.selectionStart!==t.selectionEnd||typeof t.value!="string")return;e.preventDefault();const s=t.value,r=t.selectionStart,h=s.lastIndexOf(`
12
+ `,r-1)+1,d=s.indexOf(`
13
+ `,r),G=d===-1?s.slice(h):s.slice(h,d+1);navigator.clipboard?.writeText(G).catch(()=>{}),e.key==="x"?(t.value=s.slice(0,h)+(d===-1?"":s.slice(d+1)),t.selectionStart=t.selectionEnd=h,t.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})):E.toast("Line copied",{type:"info",duration:1500})});else{const e=()=>{n.css.value=b.value};b.addEventListener("input",e),i(()=>b.removeEventListener("input",e))}i(()=>{try{u?.destroy?.()}catch{}});const Re=E.contextMenu(x,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>xe(e.dataset.id)});i(()=>Re?.destroy?.());const le=e=>{const t=ce(e.dataset.id),s=t&&Z(t.key);s?.page?window.open(s.path,"_blank","noopener"):X(e)},ue=e=>{const t=e.target.closest(".stx-row");if(!t)return;const s=e.target.closest(".stx-kebab");if(s){X(s);return}le(t)},de=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),X(t)):e.key==="Enter"&&e.target===t&&le(t))};x.addEventListener("click",ue),x.addEventListener("keydown",de),i(()=>{x.removeEventListener("click",ue),x.removeEventListener("keydown",de)}),he(c,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),he(c,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>Y()});const pe=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),Y())};document.addEventListener("keydown",pe,!0),i(()=>document.removeEventListener("keydown",pe,!0));const me=()=>{if(L.peek())try{S.set(T,{form:He(ne()),base:U.peek(),at:new Date().toISOString()})}catch{}},fe=e=>{L.peek()&&(me(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",fe),i(()=>window.removeEventListener("beforeunload",fe)),y={dirty:()=>L.peek(),dispose(){O=!0,me(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a),_();let k=null;try{k=S.get(T)}catch{}if(k?.form){try{S.remove?S.remove(T):S.set(T,null)}catch{}const e=k.base&&k.base!==U.peek(),t=k.at?` (${D(k.at).format("D MMM HH:mm")})`:"",s=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(s)&&l()){const r={...g.peek()};for(const[h,d]of Object.entries(k.form))h in r&&h!=="smtpPass"&&(r[h]=d);f||(r.css=g.peek().css),ie(r),q.peek()&&(J.value=n.adminHome.peek())}}}};
@@ -1 +1 @@
1
- import{api as Y,getUser as Ve}from"../api.js";import{addBannerAction as Ae}from"../lib/plugin-chrome.js";import{helpPopovers as Ge}from"../lib/help-popover.js";import{sidebarCount as Te}from"../lib/tool-kit.js";import{roleIndex as Je,initials as We}from"../lib/users-arrange.js";import{fieldKind as ue,fieldOptions as de}from"../lib/my-profile-arrange.js";import{buildUserPayload as Xe,checkUserForm as Re,draftOf as ze,editRights as Qe,emailConflict as Ze,formFromUser as pe,roleChoices as et,snapshot as oe}from"../lib/user-editor-arrange.js";const tt=/^\/users\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ne=a=>String(a||"").split("?")[0].replace(/\/$/,""),p=a=>String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),re=a=>{if(!a)return"";try{return D(a).format("D MMM YYYY")}catch{return String(a).slice(0,10)}},at=`<button type="button" class="uex-pick-chip" data-role="{{name}}" data-bind-class="on && 'is-on'" data-bind-disabled="disabled" title="{{title}}" data-bind-aria-pressed="on ? 'true' : 'false'">{{label}}</button>`,st=`<button type="button" class="uex-pick-chip" data-project="{{slug}}" data-bind-class="on && 'is-on'" data-bind-aria-pressed="on ? 'true' : 'false'" title="{{title}}">{{name}}</button>`;let k=null,Ne=!1;function ot(){Ne||!window.R?.use||(Ne=!0,R.use((a,r,v)=>{const c=ne(r?.path);return!(tt.test(r?.path||"")&&ne(a?.path)!==c)||!k?.dirty()||k.path!==c?v():E.confirm("You have unsaved changes to this user. Leave anyway? They are kept for your next visit - except a password, which is typed again.").then(L=>{L?v():history.replaceState(null,"",`#${c}`)})}),M.subscribe("router:afterChange",({to:a}={})=>{if(k&&ne(a?.path)!==k.path){const r=k;k=null,r.dispose()}}))}function nt(a,r){const v=ue(a.type),c=`uex-pf-${r}`,s=a.required?' <span class="text-danger">*</span>':"",L=a.help||a.description?` <span data-help="${p(a.help||a.description)}"></span>`:"",P=`<label class="form-label" for="${c}">${p(a.label||a.name)}${s}${L}</label>`,i=`<small class="uex-err" data-bind-hidden="!pe_${r}.value" data-bind-text="pe_${r}.value"></small>`,F=a.placeholder?` placeholder="${p(a.placeholder)}"`:"";switch(v){case"textarea":return`<div class="uex-wide">${P}<textarea id="${c}" class="form-input" rows="3"${F} data-model="pf_${r}.value"></textarea>${i}</div>`;case"select":return`<div>${P}<select id="${c}" class="form-input" data-model="pf_${r}.value"><option value="">-</option>${de(a).map(m=>`<option value="${p(m.value)}">${p(m.label)}</option>`).join("")}</select>${i}</div>`;case"checkbox":return`<div><label class="uex-check"><input id="${c}" type="checkbox" data-model="pf_${r}.value"> ${p(a.label||a.name)}${s}</label>${L}${i}</div>`;case"multi":return`<div class="uex-wide">${P}<div data-multi="${r}">${de(a).map((m,u)=>`<label class="uex-check"><input type="checkbox" data-opt="${p(m.value)}" data-bind-checked="po_${r}_${u}.value"> ${p(m.label)}</label>`).join("")}</div>${i}</div>`;default:return`<div>${P}<input id="${c}" type="${v}" class="form-input"${F} data-model="pf_${r}.value">${i}</div>`}}function ve(a,r){const v=a.querySelector(".dm-plugin-banner-name");if(!v)return;const c=v.querySelector(".dm-plugin-banner-parent")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-parent",textContent:"Users"}),s=v.querySelector(".dm-plugin-banner-sep")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-sep",textContent:"\u203A"});s.setAttribute("aria-hidden","true"),v.replaceChildren(c,s,document.createTextNode(r))}export const userEditorView={templateUrl:"/admin/js/templates/user-editor.html",async onMount(a){if(ot(),k){const e=k;k=null,e.dispose()}const r=ne(location.hash.slice(1)),v=r.match(/^\/users\/edit\/([^/?#]+)/),c=v?decodeURIComponent(v[1]):null,s=!c,L=s&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",P=[],i=e=>(P.push(e),e);let F=!1;const m=a.get(0),u=m.querySelector(".uex")||m,A=()=>!F&&u.isConnected,K=M.observable(!0),me=M.observable(""),V=M.observable(!1),_=M.observable(null),O=M.observable(""),y=M.observable(""),W=M.observable(""),_e=M.observable(!1),$=M.observable("user"),h=M.observable([]),X=M.observable(""),x=M.observable([]),z=M.observable(!0),q=M.observable(""),G=M.observable(""),fe=M.observable(!1),he=M.observable(!1),be=M.observable([]),ge=M.observable([]),T=M.observable({self:!1,canEdit:!0,reason:""}),B=M.observable([]);let w={};const ke=M.observable([]);let b=[];const N=new Map,ye=M.observable(0),g=M.observable(pe(null,[])),Oe=()=>(ye.value,Object.fromEntries([...N].map(([e,t])=>[e,t.value]))),qe=()=>Object.fromEntries([...N].map(([e,t])=>[e,t.peek()])),we=()=>({name:O.value,email:y.value,password:W.value,role:$.value,additionalRoles:h.value,ownedByProject:X.value,projects:x.value,isActive:z.value,profile:Oe()}),le=()=>({name:O.peek(),email:y.peek(),password:W.peek(),role:$.peek(),additionalRoles:h.peek(),ownedByProject:X.peek(),projects:x.peek(),isActive:z.peek(),profile:qe()}),Q=M.computed(()=>Re(we(),{isNew:s,fields:b,saved:s?void 0:g.value,choices:B.value})),U=M.computed(()=>!K.value&&T.value.canEdit&&oe(we())!==oe(g.value)),H=M.computed(()=>!T.value.canEdit);i(M.effect(()=>{const e=new Set(h.value);be.value=B.value.filter(t=>t.name!==$.value).map(t=>({name:t.name,label:t.label,on:e.has(t.name),disabled:!t.assignable&&!e.has(t.name),title:t.assignable||e.has(t.name)?`Level ${t.level}`:t.reason}))})),i(M.effect(()=>{const e=new Set(x.value);ge.value=ke.value.map(t=>({slug:t.slug,name:t.name||t.slug,on:e.has(t.slug),title:t.slug}))}));const Be={loading:K,loadError:me,saving:V,name:O,email:y,password:W,showPw:_e,role:$,ownedByProject:X,isActive:z,hasProfile:fe,hasProjects:he,extraChips:be,projectChips:ge,err:Q,dirty:U,locked:H,isSelf:M.computed(()=>T.value.self),lockedReason:M.computed(()=>T.value.reason),emailErr:M.computed(()=>Q.value.email||(q.value&&y.value.trim().toLowerCase()===G.value?q.value:"")),cannotSave:M.computed(()=>V.value||H.value||!U.value||Object.keys(Q.value).length>0||!!(q.value&&y.value.trim().toLowerCase()===G.value)),saveText:s?"Create user":"Save",dirtyText:s?"This user has not been created yet.":"You have unsaved changes.",pwLabel:M.computed(()=>s?"Password":"New password"),pwPlaceholder:M.computed(()=>s?"At least 8 characters":"Leave empty to keep it"),avatar:M.computed(()=>We({name:O.value,email:y.value})),shownName:M.computed(()=>O.value.trim()||(s?"New user":_.value?.name||"")),shownEmail:M.computed(()=>y.value.trim()||""),roleText:M.computed(()=>[$.value,...h.value.filter(t=>t!==$.value)].filter(Boolean).map(t=>w[t]?.label||t).join(" + ")||"-"),accessHint:M.computed(()=>x.value.length?`Sees only ${x.value.length===1?"that project":"those projects"}, plus Core.`:"Nothing ticked: no restriction - sees every project."),factsText:M.computed(()=>{const e=_.value;return s||!e?"Not created yet":[e.createdAt&&`Added ${re(e.createdAt)}`,e.lastLogin?`last signed in ${re(e.lastLogin)}`:"never signed in",e.updatedAt&&`changed ${re(e.updatedAt)}`].filter(Boolean).join(" \xB7 ")})},Z=`user-editor.draft.${c||"new"}`;function ee(e){O.value=e.name,y.value=e.email,W.value="",$.value=e.role,h.value=[...e.additionalRoles||[]],X.value=e.ownedByProject||"",x.value=[...e.projects||[]],z.value=e.isActive!==!1;for(const[t,o]of N)t in(e.profile||{})&&(o.value=e.profile[t])}function Ie(e,t){const o=Ze(e);return o?(q.value=o,G.value=String(t||"").trim().toLowerCase(),u.querySelector("#uex-email")?.focus(),!0):!1}async function ie(){if(V.peek()||K.peek()||H.peek())return;const e=le(),t=Re(e,{isNew:s,fields:b,saved:s?void 0:g.peek(),choices:B.peek()});if(Object.keys(t).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const o=Xe(g.peek(),e,{isNew:s,fields:b,meta:_.peek()?.meta||{}});if(!o){E.toast("Nothing has changed.",{type:"info"});return}if(!(!s&&T.peek().self&&o.update.role&&(w[o.update.role]?.level??99)>(w[g.peek().role]?.level??99)&&!await E.confirm(`Give yourself the less senior role "${p(w[o.update.role]?.label||o.update.role)}"? You will not be able to raise it again yourself.`))){V.value=!0;try{if(s){const d=await Y.users.create(o.create);if(!A())return;te(),g.value=le(),E.toast(`Created ${d?.name||e.name.trim()}.`,{type:"success"}),Te("#/users"),location.hash=d?.id?`#/users/edit/${encodeURIComponent(d.id)}`:"#/users";return}const n=await Y.users.update(c,o.update);if(!A())return;_.value=n;const l=pe(n,b);if(g.value=l,ee(l),te(),ve(m,n.name||n.email||"Edit"),T.peek().self){try{S.set("auth_user",{...S.get("auth_user")||{},name:n.name,email:n.email})}catch{}const d=document.getElementById("topbar-user-name");d&&n.name&&(d.textContent=n.name)}Te("#/users"),E.toast("Saved.",{type:"success"})}catch(n){Ie(n,e.email)||E.toast(`Not saved: ${n.message||n}`,{type:"error"})}finally{V.value=!1}}}async function Ue(){U.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!A()||(ee(g.peek()),te()))}function $e(){if(!U.peek())return;const e=ze(le());if(oe({...e,password:""})!==oe(g.peek()))try{S.set(Z,{form:e,at:new Date().toISOString(),basedOn:_.peek()?.updatedAt||null})}catch{}}function te(){try{S.remove?S.remove(Z):S.set(Z,null)}catch{}}u.querySelector(".uex-roles").innerHTML=at,u.querySelector(".uex-projects").innerHTML=st;const Me=e=>{if(H.peek())return;const t=e.target.closest(".uex-pick-chip");if(!(!t||t.disabled)){if(t.dataset.role){const o=t.dataset.role,n=h.peek();h.value=n.includes(o)?n.filter(l=>l!==o):[...n,o]}else if(t.dataset.project){const o=t.dataset.project,n=x.peek();x.value=n.includes(o)?n.filter(l=>l!==o):[...n,o]}}};u.addEventListener("click",Me),i(()=>u.removeEventListener("click",Me)),i(M.effect(()=>{q.peek()&&y.value.trim().toLowerCase()!==G.peek()&&(q.value="",G.value="")})),i(M.effect(()=>{const e=$.value;h.peek().includes(e)&&(h.value=h.peek().filter(t=>t!==e))})),Ae(a,{id:"user-editor-back",icon:"arrow-left",title:"Back to users",onClick:()=>{location.hash="#/users"}}),Ae(a,{id:"user-editor-save",icon:"save",title:s?"Create user (Ctrl+S)":"Save (Ctrl+S)",onClick:()=>ie()}),ve(m,s?"New user":"Edit");const Ee=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!A()||(e.preventDefault(),e.stopImmediatePropagation(),ie())};document.addEventListener("keydown",Ee,!0),i(()=>document.removeEventListener("keydown",Ee,!0));const Se=e=>{U.peek()&&($e(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Se),i(()=>window.removeEventListener("beforeunload",Se)),k={path:r,dirty:()=>U.peek(),dispose(){F=!0,$e(),P.splice(0).reverse().forEach(e=>{try{e()}catch{}})}};let f=null,xe=null,je=null,j=[];try{[f,je,xe,j]=await Promise.all([s?Promise.resolve(null):Y.users.get(c),Y.collections.listEntries("roles",{limit:1e3}).catch(()=>null),Y.collections.get("user-profiles").catch(()=>null),Y.projects.list().catch(()=>[])])}catch(e){if(!A())return;me.value=e?.status===404?"There is no such user - it may have been deleted.":`Could not load this user: ${e.message||e}`,K.value=!1,I.scan(u);return}if(!A())return;j=Array.isArray(j)?j:[],ke.value=j,w=Je(je?.entries||[]),Object.keys(w).length||(w={admin:{label:"Admin",level:1},user:{label:"User",level:2}});const Ce=Ve()||{};T.value=Qe(Ce,f,w),B.value=et(w,Ce,f),_.value=f;const He=u.querySelector(".uex-role-select");He.innerHTML=B.peek().map(e=>`<option value="${p(e.name)}"${e.assignable?"":" disabled"}>${p(e.label)}${e.assignable?"":" - only a more senior role can give it"}</option>`).join("");const De=u.querySelector(".uex-owner-select");De.innerHTML=`<option value="">(none)</option>${j.map(e=>`<option value="${p(e.slug)}">${p(e.name||e.slug)}</option>`).join("")}`,he.value=j.length>0;const Le=B.peek().filter(e=>e.assignable).map(e=>e.name),Ye=Le.includes("user")?"user":Le.at(-1)||"user";b=(xe?.fields||[]).filter(e=>e&&e.name);const J=pe(f,b,{project:j.some(e=>e.slug===L)?L:"",defaultRole:Ye});b.forEach(e=>N.set(e.name,M.observable(J.profile[e.name])));const Fe=M.applyBindings(Be,u,{methods:{save:ie,discard:Ue}});i(()=>Fe.dispose());const ce=b.map((e,t)=>({f:e,i:t})).filter(({f:e})=>ue(e.type)!=="hidden");if(ce.length){const e=u.querySelector(".uex-profile");e.innerHTML=ce.map(({f:l,i:d})=>nt(l,d)).join("");const t={};b.forEach((l,d)=>{t[`pf_${d}`]=N.get(l.name),t[`pe_${d}`]=M.computed(()=>Q.value[`profile.${l.name}`]||""),ue(l.type)==="multi"&&de(l).forEach((ae,se)=>{t[`po_${d}_${se}`]=M.computed(()=>(N.get(l.name).value||[]).includes(ae.value))})});const o=M.applyBindings(t,e);i(()=>o.dispose());const n=l=>{const d=l.target.closest("[data-multi]");if(!d||!l.target.matches("input[data-opt]"))return;const ae=N.get(b[Number(d.dataset.multi)].name),se=l.target.dataset.opt,Pe=ae.peek()||[];ae.value=l.target.checked?[...new Set([...Pe,se])]:Pe.filter(Ke=>Ke!==se)};e.addEventListener("change",n),i(()=>e.removeEventListener("change",n))}fe.value=ce.length>0,i(Ge(u,{position:"bottom"})),g.value=J,ee(J),ye.value++,K.value=!1,ve(m,s?"New user":f.name||f.email||"Edit"),I.scan(u),H.peek()||u.querySelector("#uex-name")?.focus();let C=null;try{C=S.get(Z)}catch{}if(C?.form&&!H.peek()){te();const e=C.at?` (${re(C.at)})`:"",t=!s&&C.basedOn&&f?.updatedAt&&C.basedOn!==f.updatedAt?" This user has been changed since - restoring puts your version over it.":"",o=s?"the new user you started":`"${p(f.name||f.email)}"`;await E.confirm(`You left unsaved changes to ${o}${e}.${t} Restore them?`)&&A()&&ee({...J,...C.form,profile:{...J.profile,...C.form.profile||{}}})}}};
1
+ import{api as _,getUser as Ge}from"../api.js";import{addBannerAction as Te}from"../lib/plugin-chrome.js";import{resetPassword as Je}from"../lib/password-reset-actions.js";import{helpPopovers as We}from"../lib/help-popover.js";import{sidebarCount as Ne}from"../lib/tool-kit.js";import{roleIndex as Xe,initials as ze}from"../lib/users-arrange.js";import{fieldKind as de,fieldOptions as pe}from"../lib/my-profile-arrange.js";import{buildUserPayload as Qe,checkUserForm as Oe,draftOf as Ze,editRights as et,emailConflict as tt,formFromUser as ve,roleChoices as at,snapshot as oe}from"../lib/user-editor-arrange.js";const st=/^\/users\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ne=a=>String(a||"").split("?")[0].replace(/\/$/,""),p=a=>String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),re=a=>{if(!a)return"";try{return D(a).format("D MMM YYYY")}catch{return String(a).slice(0,10)}},ot=`<button type="button" class="uex-pick-chip" data-role="{{name}}" data-bind-class="on && 'is-on'" data-bind-disabled="disabled" title="{{title}}" data-bind-aria-pressed="on ? 'true' : 'false'">{{label}}</button>`,nt=`<button type="button" class="uex-pick-chip" data-project="{{slug}}" data-bind-class="on && 'is-on'" data-bind-aria-pressed="on ? 'true' : 'false'" title="{{title}}">{{name}}</button>`;let y=null,_e=!1;function rt(){_e||!window.R?.use||(_e=!0,R.use((a,r,v)=>{const c=ne(r?.path);return!(st.test(r?.path||"")&&ne(a?.path)!==c)||!y?.dirty()||y.path!==c?v():E.confirm("You have unsaved changes to this user. Leave anyway? They are kept for your next visit - except a password, which is typed again.").then(A=>{A?v():history.replaceState(null,"",`#${c}`)})}),M.subscribe("router:afterChange",({to:a}={})=>{if(y&&ne(a?.path)!==y.path){const r=y;y=null,r.dispose()}}))}function lt(a,r){const v=de(a.type),c=`uex-pf-${r}`,s=a.required?' <span class="text-danger">*</span>':"",A=a.help||a.description?` <span data-help="${p(a.help||a.description)}"></span>`:"",T=`<label class="form-label" for="${c}">${p(a.label||a.name)}${s}${A}</label>`,i=`<small class="uex-err" data-bind-hidden="!pe_${r}.value" data-bind-text="pe_${r}.value"></small>`,K=a.placeholder?` placeholder="${p(a.placeholder)}"`:"";switch(v){case"textarea":return`<div class="uex-wide">${T}<textarea id="${c}" class="form-input" rows="3"${K} data-model="pf_${r}.value"></textarea>${i}</div>`;case"select":return`<div>${T}<select id="${c}" class="form-input" data-model="pf_${r}.value"><option value="">-</option>${pe(a).map(m=>`<option value="${p(m.value)}">${p(m.label)}</option>`).join("")}</select>${i}</div>`;case"checkbox":return`<div><label class="uex-check"><input id="${c}" type="checkbox" data-model="pf_${r}.value"> ${p(a.label||a.name)}${s}</label>${A}${i}</div>`;case"multi":return`<div class="uex-wide">${T}<div data-multi="${r}">${pe(a).map((m,u)=>`<label class="uex-check"><input type="checkbox" data-opt="${p(m.value)}" data-bind-checked="po_${r}_${u}.value"> ${p(m.label)}</label>`).join("")}</div>${i}</div>`;default:return`<div>${T}<input id="${c}" type="${v}" class="form-input"${K} data-model="pf_${r}.value">${i}</div>`}}function me(a,r){const v=a.querySelector(".dm-plugin-banner-name");if(!v)return;const c=v.querySelector(".dm-plugin-banner-parent")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-parent",textContent:"Users"}),s=v.querySelector(".dm-plugin-banner-sep")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-sep",textContent:"\u203A"});s.setAttribute("aria-hidden","true"),v.replaceChildren(c,s,document.createTextNode(r))}export const userEditorView={templateUrl:"/admin/js/templates/user-editor.html",async onMount(a){if(rt(),y){const e=y;y=null,e.dispose()}const r=ne(location.hash.slice(1)),v=r.match(/^\/users\/edit\/([^/?#]+)/),c=v?decodeURIComponent(v[1]):null,s=!c,A=s&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",T=[],i=e=>(T.push(e),e);let K=!1;const m=a.get(0),u=m.querySelector(".uex")||m,N=()=>!K&&u.isConnected,B=M.observable(!0),fe=M.observable(""),V=M.observable(!1),f=M.observable(null),q=M.observable(""),w=M.observable(""),W=M.observable(""),Be=M.observable(!1),j=M.observable("user"),b=M.observable([]),X=M.observable(""),C=M.observable([]),z=M.observable(!0),U=M.observable(""),G=M.observable(""),he=M.observable(!1),be=M.observable(!1),ge=M.observable([]),ke=M.observable([]),$=M.observable({self:!1,canEdit:!0,reason:""}),H=M.observable([]);let x={};const ye=M.observable([]);let g=[];const O=new Map,we=M.observable(0),k=M.observable(ve(null,[])),qe=()=>(we.value,Object.fromEntries([...O].map(([e,t])=>[e,t.value]))),De=()=>Object.fromEntries([...O].map(([e,t])=>[e,t.peek()])),$e=()=>({name:q.value,email:w.value,password:W.value,role:j.value,additionalRoles:b.value,ownedByProject:X.value,projects:C.value,isActive:z.value,profile:qe()}),le=()=>({name:q.peek(),email:w.peek(),password:W.peek(),role:j.peek(),additionalRoles:b.peek(),ownedByProject:X.peek(),projects:C.peek(),isActive:z.peek(),profile:De()}),Q=M.computed(()=>Oe($e(),{isNew:s,fields:g,saved:s?void 0:k.value,choices:H.value})),Y=M.computed(()=>!B.value&&$.value.canEdit&&oe($e())!==oe(k.value)),F=M.computed(()=>!$.value.canEdit);i(M.effect(()=>{const e=new Set(b.value);ge.value=H.value.filter(t=>t.name!==j.value).map(t=>({name:t.name,label:t.label,on:e.has(t.name),disabled:!t.assignable&&!e.has(t.name),title:t.assignable||e.has(t.name)?`Level ${t.level}`:t.reason}))})),i(M.effect(()=>{const e=new Set(C.value);ke.value=ye.value.map(t=>({slug:t.slug,name:t.name||t.slug,on:e.has(t.slug),title:t.slug}))}));const ie={loading:B,loadError:fe,saving:V,name:q,email:w,password:W,showPw:Be,role:j,ownedByProject:X,isActive:z,hasProfile:he,hasProjects:be,extraChips:ge,projectChips:ke,err:Q,dirty:Y,locked:F,isSelf:M.computed(()=>$.value.self),lockedReason:M.computed(()=>$.value.reason),emailErr:M.computed(()=>Q.value.email||(U.value&&w.value.trim().toLowerCase()===G.value?U.value:"")),cannotSave:M.computed(()=>V.value||F.value||!Y.value||Object.keys(Q.value).length>0||!!(U.value&&w.value.trim().toLowerCase()===G.value)),saveText:s?"Create user":"Save",dirtyText:s?"This user has not been created yet.":"You have unsaved changes.",pwLabel:M.computed(()=>s?"Password":"New password"),pwPlaceholder:M.computed(()=>s?"At least 8 characters":"Leave empty to keep it"),avatar:M.computed(()=>ze({name:q.value,email:w.value})),shownName:M.computed(()=>q.value.trim()||(s?"New user":f.value?.name||"")),shownEmail:M.computed(()=>w.value.trim()||""),roleText:M.computed(()=>[j.value,...b.value.filter(t=>t!==j.value)].filter(Boolean).map(t=>x[t]?.label||t).join(" + ")||"-"),accessHint:M.computed(()=>C.value.length?`Sees only ${C.value.length===1?"that project":"those projects"}, plus Core.`:"Nothing ticked: no restriction - sees every project."),factsText:M.computed(()=>{const e=f.value;return s||!e?"Not created yet":[e.createdAt&&`Added ${re(e.createdAt)}`,e.lastLogin?`last signed in ${re(e.lastLogin)}`:"never signed in",e.updatedAt&&`changed ${re(e.updatedAt)}`].filter(Boolean).join(" \xB7 ")})},Z=`user-editor.draft.${c||"new"}`;function ee(e){q.value=e.name,w.value=e.email,W.value="",j.value=e.role,b.value=[...e.additionalRoles||[]],X.value=e.ownedByProject||"",C.value=[...e.projects||[]],z.value=e.isActive!==!1;for(const[t,o]of O)t in(e.profile||{})&&(o.value=e.profile[t])}function Ie(e,t){const o=tt(e);return o?(U.value=o,G.value=String(t||"").trim().toLowerCase(),u.querySelector("#uex-email")?.focus(),!0):!1}async function ce(){if(V.peek()||B.peek()||F.peek())return;const e=le(),t=Oe(e,{isNew:s,fields:g,saved:s?void 0:k.peek(),choices:H.peek()});if(Object.keys(t).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const o=Qe(k.peek(),e,{isNew:s,fields:g,meta:f.peek()?.meta||{}});if(!o){E.toast("Nothing has changed.",{type:"info"});return}if(!(!s&&$.peek().self&&o.update.role&&(x[o.update.role]?.level??99)>(x[k.peek().role]?.level??99)&&!await E.confirm(`Give yourself the less senior role "${p(x[o.update.role]?.label||o.update.role)}"? You will not be able to raise it again yourself.`))){V.value=!0;try{if(s){const d=await _.users.create(o.create);if(!N())return;te(),k.value=le(),E.toast(`Created ${d?.name||e.name.trim()}.`,{type:"success"}),Ne("#/users"),location.hash=d?.id?`#/users/edit/${encodeURIComponent(d.id)}`:"#/users";return}const n=await _.users.update(c,o.update);if(!N())return;f.value=n;const l=ve(n,g);if(k.value=l,ee(l),te(),me(m,n.name||n.email||"Edit"),$.peek().self){try{S.set("auth_user",{...S.get("auth_user")||{},name:n.name,email:n.email})}catch{}const d=document.getElementById("topbar-user-name");d&&n.name&&(d.textContent=n.name)}Ne("#/users"),E.toast("Saved.",{type:"success"})}catch(n){Ie(n,e.email)||E.toast(`Not saved: ${n.message||n}`,{type:"error"})}finally{V.value=!1}}}async function Ue(){Y.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!N()||(ee(k.peek()),te()))}function Me(){if(!Y.peek())return;const e=Ze(le());if(oe({...e,password:""})!==oe(k.peek()))try{S.set(Z,{form:e,at:new Date().toISOString(),basedOn:f.peek()?.updatedAt||null})}catch{}}function te(){try{S.remove?S.remove(Z):S.set(Z,null)}catch{}}u.querySelector(".uex-roles").innerHTML=ot,u.querySelector(".uex-projects").innerHTML=nt;const Ee=e=>{if(F.peek())return;const t=e.target.closest(".uex-pick-chip");if(!(!t||t.disabled)){if(t.dataset.role){const o=t.dataset.role,n=b.peek();b.value=n.includes(o)?n.filter(l=>l!==o):[...n,o]}else if(t.dataset.project){const o=t.dataset.project,n=C.peek();C.value=n.includes(o)?n.filter(l=>l!==o):[...n,o]}}};u.addEventListener("click",Ee),i(()=>u.removeEventListener("click",Ee)),i(M.effect(()=>{U.peek()&&w.value.trim().toLowerCase()!==G.peek()&&(U.value="",G.value="")})),i(M.effect(()=>{const e=j.value;b.peek().includes(e)&&(b.value=b.peek().filter(t=>t!==e))})),Te(a,{id:"user-editor-back",icon:"arrow-left",title:"Back to users",onClick:()=>{location.hash="#/users"}}),Te(a,{id:"user-editor-save",icon:"save",title:s?"Create user (Ctrl+S)":"Save (Ctrl+S)",onClick:()=>ce()}),me(m,s?"New user":"Edit");const xe=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!N()||(e.preventDefault(),e.stopImmediatePropagation(),ce())};document.addEventListener("keydown",xe,!0),i(()=>document.removeEventListener("keydown",xe,!0));const Se=e=>{Y.peek()&&(Me(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Se),i(()=>window.removeEventListener("beforeunload",Se)),y={path:r,dirty:()=>Y.peek(),dispose(){K=!0,Me(),T.splice(0).reverse().forEach(e=>{try{e()}catch{}})}};let h=null,je=null,Ce=null,L=[];try{[h,Ce,je,L]=await Promise.all([s?Promise.resolve(null):_.users.get(c),_.collections.listEntries("roles",{limit:1e3}).catch(()=>null),_.collections.get("user-profiles").catch(()=>null),_.projects.list().catch(()=>[])])}catch(e){if(!N())return;fe.value=e?.status===404?"There is no such user - it may have been deleted.":`Could not load this user: ${e.message||e}`,B.value=!1,I.scan(u);return}if(!N())return;L=Array.isArray(L)?L:[],ye.value=L,x=Xe(Ce?.entries||[]),Object.keys(x).length||(x={admin:{label:"Admin",level:1},user:{label:"User",level:2}});const Le=Ge()||{};$.value=et(Le,h,x),H.value=at(x,Le,h),f.value=h;const He=u.querySelector(".uex-role-select");He.innerHTML=H.peek().map(e=>`<option value="${p(e.name)}"${e.assignable?"":" disabled"}>${p(e.label)}${e.assignable?"":" - only a more senior role can give it"}</option>`).join("");const Ye=u.querySelector(".uex-owner-select");Ye.innerHTML=`<option value="">(none)</option>${L.map(e=>`<option value="${p(e.slug)}">${p(e.name||e.slug)}</option>`).join("")}`,be.value=L.length>0;const Pe=H.peek().filter(e=>e.assignable).map(e=>e.name),Fe=Pe.includes("user")?"user":Pe.at(-1)||"user";g=(je?.fields||[]).filter(e=>e&&e.name);const J=ve(h,g,{project:L.some(e=>e.slug===A)?A:"",defaultRole:Fe});g.forEach(e=>O.set(e.name,M.observable(J.profile[e.name])));const Ae=async e=>{const t=f.peek();if(t&&await Je(t,e))try{f.value={...t,...await _.users.get(t.id)}}catch{}};ie.canReset=M.computed(()=>!s&&!B.value&&$.value.canEdit&&!$.value.self&&f.value?.isActive!==!1),ie.resetOpen=M.computed(()=>!!f.value?.resetLinkExpires&&Date.parse(f.value.resetLinkExpires)>Date.now());const Ke=M.applyBindings(ie,u,{methods:{save:ce,discard:Ue,resetEmail:()=>Ae("email"),resetLink:()=>Ae("link")}});i(()=>Ke.dispose());const ue=g.map((e,t)=>({f:e,i:t})).filter(({f:e})=>de(e.type)!=="hidden");if(ue.length){const e=u.querySelector(".uex-profile");e.innerHTML=ue.map(({f:l,i:d})=>lt(l,d)).join("");const t={};g.forEach((l,d)=>{t[`pf_${d}`]=O.get(l.name),t[`pe_${d}`]=M.computed(()=>Q.value[`profile.${l.name}`]||""),de(l.type)==="multi"&&pe(l).forEach((ae,se)=>{t[`po_${d}_${se}`]=M.computed(()=>(O.get(l.name).value||[]).includes(ae.value))})});const o=M.applyBindings(t,e);i(()=>o.dispose());const n=l=>{const d=l.target.closest("[data-multi]");if(!d||!l.target.matches("input[data-opt]"))return;const ae=O.get(g[Number(d.dataset.multi)].name),se=l.target.dataset.opt,Re=ae.peek()||[];ae.value=l.target.checked?[...new Set([...Re,se])]:Re.filter(Ve=>Ve!==se)};e.addEventListener("change",n),i(()=>e.removeEventListener("change",n))}he.value=ue.length>0,i(We(u,{position:"bottom"})),k.value=J,ee(J),we.value++,B.value=!1,me(m,s?"New user":h.name||h.email||"Edit"),I.scan(u),F.peek()||u.querySelector("#uex-name")?.focus();let P=null;try{P=S.get(Z)}catch{}if(P?.form&&!F.peek()){te();const e=P.at?` (${re(P.at)})`:"",t=!s&&P.basedOn&&h?.updatedAt&&P.basedOn!==h.updatedAt?" This user has been changed since - restoring puts your version over it.":"",o=s?"the new user you started":`"${p(h.name||h.email)}"`;await E.confirm(`You left unsaved changes to ${o}${e}.${t} Restore them?`)&&N()&&ee({...J,...P.form,profile:{...J.profile,...P.form.profile||{}}})}}};
@@ -1,4 +1,4 @@
1
- import{api as N,apiRequest as U,getUser as X}from"../api.js";import{addBannerAction as Z}from"../lib/plugin-chrome.js";import{helpPopovers as ee}from"../lib/help-popover.js";import{openMenuAt as L,settingsStore as te,sidebarCount as q,viewLifetime as se}from"../lib/tool-kit.js";import{filterByProject as oe}from"../lib/project-context.js";import{FILTERS as ae,SORTS as ne,arrangeUsers as ie,canManage as P,roleIndex as re,userRow as le}from"../lib/users-arrange.js";const Y=/^#\/(projects\/[^/?#]+\/)?users\/?(\?.*)?$/,B="#/users",ce=`
1
+ import{api as B,apiRequest as O,getUser as ae}from"../api.js";import{addBannerAction as oe}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{openMenuAt as Y,settingsStore as ie,sidebarCount as v,viewLifetime as re}from"../lib/tool-kit.js";import{resetPassword as le,withdrawReset as ce}from"../lib/password-reset-actions.js";import{filterByProject as de}from"../lib/project-context.js";import{FILTERS as ue,SORTS as pe,arrangeUsers as me,canManage as F,resetAllowed as he,roleIndex as fe,userRow as ve}from"../lib/users-arrange.js";const H=/^#\/(projects\/[^/?#]+\/)?users\/?(\?.*)?$/,b="#/users",be=`
2
2
  <div class="usx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="usx-avatar" aria-hidden="true">{{initials}}</span>
4
4
  <span class="usx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
@@ -8,6 +8,7 @@ import{api as N,apiRequest as U,getUser as X}from"../api.js";import{addBannerAct
8
8
  <span class="usx-chip is-outline" data-bind-hidden="!extraRoles" title="Additional roles">+ {{extraRoles}}</span>
9
9
  <span class="usx-chip" data-bind-hidden="!scope" title="Project access - sees only these (and Core)">{{scope}}</span>
10
10
  <span class="usx-chip is-warn" data-bind-hidden="!inactive" title="Cannot sign in">Inactive</span>
11
+ <span class="usx-chip is-outline" data-bind-hidden="!resetOpen" title="A password reset link was sent and has not been used yet">Reset link open</span>
11
12
  </span>
12
13
  <button type="button" class="usx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
13
- </div>`,O=o=>String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),F=te("users.list",o=>({filter:ae.includes(o?.filter)?o.filter:"all",sort:ne.includes(o?.sort)?o.sort:"name"})),n=se(Y);function de(){const o=location.hash.match(/^#\/projects\/([^/?#]+)\/users/);if(!o)return null;try{return decodeURIComponent(o[1])}catch{return o[1]}}export const usersView={templateUrl:"/admin/js/templates/users.html",async onMount(o){n.reset();const i=o.get(0).querySelector(".usx")||o.get(0),p=()=>Y.test(location.hash)&&i.isConnected,m=X(),r=de(),w=F.read(),l=M.observable([]),u=M.observable({}),x=M.observable(!0),h=M.observable(""),c=M.observable(w.filter),v=M.observable(w.sort),f=M.observable(""),k=M.observable(""),b=M.observable(0);n.add(M.effect(()=>{F.write({filter:c.value,sort:v.value})}));const R=M.observable([]);n.add(M.effect(()=>{const e=u.value,t=s=>{try{return D(s).fromNow?D(s).fromNow():D(s).format("D MMM YYYY HH:mm")}catch{return s}};R.value=ie(l.value,{filter:c.value,role:f.value,query:k.value,sort:v.value}).map(s=>le(s,{roles:e,me:m,when:t}))}));const H=M.computed(()=>{const e=l.value,t=e.filter(s=>s.isActive===!1).length;return{all:e.length,active:e.length-t,inactive:t}}),K=M.computed(()=>l.value.length?"Nobody matches.":"No users yet - New user in the banner adds one."),_=M.computed(()=>r?`Showing only users in the project "${r}"${b.value?` - ${b.value} more elsewhere`:""}.`:""),$=e=>l.peek().find(t=>t.id===e);async function y(){try{const[e,t]=await Promise.all([N.users.list(),U("/collections/roles/entries?limit=100",{method:"GET"}).catch(()=>({entries:[]}))]);if(!p())return;let s=Array.isArray(e)?e:[];if(r){const a=oe(s,r);b.value=s.length-a.length,s=a}u.value=re(t?.entries||[]),G(),l.value=s,h.value=""}catch(e){h.value=`Could not load the users: ${e.message||e}`}finally{x.value=!1}}function G(){const e=i.querySelector('.usx-select[aria-label="Role"]');if(!e)return;const t=f.peek();e.querySelectorAll('option:not([value=""])').forEach(s=>s.remove()),Object.entries(u.peek()).sort((s,a)=>s[1].level-a[1].level).forEach(([s,a])=>{const g=document.createElement("option");g.value=s,g.textContent=a.label,e.appendChild(g)}),e.value=t}const A=e=>{location.hash=`#/users/edit/${encodeURIComponent(e)}`};async function C(e,t){if(!(!t&&!await E.confirm(`Make ${O(e.name||e.email)} inactive? They will not be able to sign in until the account is made active again. Their work stays.`)))try{await U(`/users/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({isActive:t})}),E.toast(`${e.name||e.email} is ${t?"active":"inactive"}.`,{type:"success"}),q(B),p()&&await y()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}async function J(e){if(await E.confirm(`Delete ${O(e.name||e.email)}? They lose access at once and this cannot be undone.`))try{await N.delete(`/users/${encodeURIComponent(e.id)}`),E.toast(`Deleted ${e.name||e.email}.`,{type:"success"}),q(B),p()&&await y()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function V(e){try{await navigator.clipboard.writeText(e.email||""),E.toast("Email copied.",{type:"success"})}catch{E.toast(e.email||"No email",{type:"info",duration:6e3})}}function W(e){const t=$(e);if(!t)return[];const s=P(m,t,u.peek()),a=s.self?"not on your own account":"your role is not senior to theirs";return[{header:t.name||t.email},{label:s.self?"Edit my account":s.edit?"Edit":`Edit - ${a}`,icon:"edit",disabled:!s.edit,action:()=>A(t.id)},{label:"Copy email",icon:"copy",disabled:!t.email,action:()=>V(t)},{type:"divider"},t.isActive===!1?{label:s.other?"Make active":`Make active - ${a}`,icon:"play",disabled:!s.other,action:()=>C(t,!0)}:{label:s.other?"Make inactive\u2026":`Make inactive - ${a}`,icon:"pause",disabled:!s.other,action:()=>C(t,!1)},{label:s.other?"Delete\u2026":`Delete - ${a}`,icon:"trash",danger:s.other,disabled:!s.other,action:()=>J(t)}]}const d=i.querySelector(".usx-list");d.innerHTML=ce;const z=M.applyBindings({users:l,loading:x,loadError:h,filter:c,sort:v,role:f,query:k,rows:R,counts:H,emptyText:K,projectNote:_},i,{methods:{showAll:()=>{c.value="all"},showActive:()=>{c.value="active"},showInactive:()=>{c.value="inactive"}}});n.add(()=>z.dispose()),n.add(ee(i,{position:"bottom"}));const Q=E.contextMenu(d,{cursor:"glyph",match:".usx-row",inherit:!1,density:"compact",items:e=>W(e.dataset.id)});n.add(()=>Q?.destroy?.());const S=e=>{const t=$(e);t&&(P(m,t,u.peek()).edit?A(e):E.toast("Your role is not senior to theirs, so you cannot edit this account.",{type:"info"}))},T=e=>{const t=e.target.closest(".usx-row");if(!t)return;const s=e.target.closest(".usx-kebab");if(s){L(s);return}S(t.dataset.id)},j=e=>{const t=e.target.closest(".usx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),L(t)):e.key==="Enter"&&e.target===t&&S(t.dataset.id))};d.addEventListener("click",T),d.addEventListener("keydown",j),n.add(()=>{d.removeEventListener("click",T),d.removeEventListener("keydown",j)}),Z(o,{id:"users-new",icon:"plus",title:"New user",onClick:()=>{location.hash=r?`#/users/new?project=${encodeURIComponent(r)}`:"#/users/new"}}),I.scan(i),await y()}};
14
+ </div>`,W=a=>String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),K=ie("users.list",a=>({filter:ue.includes(a?.filter)?a.filter:"all",sort:pe.includes(a?.sort)?a.sort:"name"})),i=re(H);function ye(){const a=location.hash.match(/^#\/projects\/([^/?#]+)\/users/);if(!a)return null;try{return decodeURIComponent(a[1])}catch{return a[1]}}export const usersView={templateUrl:"/admin/js/templates/users.html",async onMount(a){i.reset();const r=a.get(0).querySelector(".usx")||a.get(0),m=()=>H.test(location.hash)&&r.isConnected,f=ae(),l=ye(),x=K.read(),c=M.observable([]),d=M.observable({}),R=M.observable(!0),y=M.observable(""),u=M.observable(x.filter),w=M.observable(x.sort),g=M.observable(""),$=M.observable(""),k=M.observable(0);i.add(M.effect(()=>{K.write({filter:u.value,sort:w.value})}));const A=M.observable([]);i.add(M.effect(()=>{const e=d.value,t=s=>{try{return D(s).fromNow?D(s).fromNow():D(s).format("D MMM YYYY HH:mm")}catch{return s}};A.value=me(c.value,{filter:u.value,role:g.value,query:$.value,sort:w.value}).map(s=>ve(s,{roles:e,me:f,when:t}))}));const _=M.computed(()=>{const e=c.value,t=e.filter(s=>s.isActive===!1).length;return{all:e.length,active:e.length-t,inactive:t}}),G=M.computed(()=>c.value.length?"Nobody matches.":"No users yet - New user in the banner adds one."),J=M.computed(()=>l?`Showing only users in the project "${l}"${k.value?` - ${k.value} more elsewhere`:""}.`:""),C=e=>c.peek().find(t=>t.id===e);async function h(){try{const[e,t]=await Promise.all([B.users.list(),O("/collections/roles/entries?limit=100",{method:"GET"}).catch(()=>({entries:[]}))]);if(!m())return;let s=Array.isArray(e)?e:[];if(l){const o=de(s,l);k.value=s.length-o.length,s=o}d.value=fe(t?.entries||[]),V(),c.value=s,y.value=""}catch(e){y.value=`Could not load the users: ${e.message||e}`}finally{R.value=!1}}function V(){const e=r.querySelector('.usx-select[aria-label="Role"]');if(!e)return;const t=g.peek();e.querySelectorAll('option:not([value=""])').forEach(s=>s.remove()),Object.entries(d.peek()).sort((s,o)=>s[1].level-o[1].level).forEach(([s,o])=>{const n=document.createElement("option");n.value=s,n.textContent=o.label,e.appendChild(n)}),e.value=t}const S=e=>{location.hash=`#/users/edit/${encodeURIComponent(e)}`};async function T(e,t){if(!(!t&&!await E.confirm(`Make ${W(e.name||e.email)} inactive? They will not be able to sign in until the account is made active again. Their work stays.`)))try{await O(`/users/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({isActive:t})}),E.toast(`${e.name||e.email} is ${t?"active":"inactive"}.`,{type:"success"}),v(b),m()&&await h()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}async function z(e){if(await E.confirm(`Delete ${W(e.name||e.email)}? They lose access at once and this cannot be undone.`))try{await B.delete(`/users/${encodeURIComponent(e.id)}`),E.toast(`Deleted ${e.name||e.email}.`,{type:"success"}),v(b),m()&&await h()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function j(e,t){await le(e,t)&&(v(b),m()&&await h())}async function Q(e){await ce(e)&&(v(b),m()&&await h())}async function X(e){try{await navigator.clipboard.writeText(e.email||""),E.toast("Email copied.",{type:"success"})}catch{E.toast(e.email||"No email",{type:"info",duration:6e3})}}function Z(e){const t=C(e);if(!t)return[];const s=F(f,t,d.peek()),o=s.self?"not on your own account":"your role is not senior to theirs",n=he(f,t,d.peek()),se=!!t.resetLinkExpires&&Date.parse(t.resetLinkExpires)>Date.now(),P=q=>n.ok?q:`${q.replace(/…$/,"")} - ${n.why}`;return[{header:t.name||t.email},{label:s.self?"Edit my account":s.edit?"Edit":`Edit - ${o}`,icon:"edit",disabled:!s.edit,action:()=>S(t.id)},{label:"Copy email",icon:"copy",disabled:!t.email,action:()=>X(t)},{type:"divider"},{label:P("Send password reset email\u2026"),icon:"mail",disabled:!n.ok,action:()=>j(t,"email")},{label:P("Copy a reset link"),icon:"link",disabled:!n.ok,action:()=>j(t,"link")},...se?[{label:s.other?"Withdraw the reset link":`Withdraw the reset link - ${o}`,icon:"x-circle",disabled:!s.other,action:()=>Q(t)}]:[],{type:"divider"},t.isActive===!1?{label:s.other?"Make active":`Make active - ${o}`,icon:"play",disabled:!s.other,action:()=>T(t,!0)}:{label:s.other?"Make inactive\u2026":`Make inactive - ${o}`,icon:"pause",disabled:!s.other,action:()=>T(t,!1)},{label:s.other?"Delete\u2026":`Delete - ${o}`,icon:"trash",danger:s.other,disabled:!s.other,action:()=>z(t)}]}const p=r.querySelector(".usx-list");p.innerHTML=be;const ee=M.applyBindings({users:c,loading:R,loadError:y,filter:u,sort:w,role:g,query:$,rows:A,counts:_,emptyText:G,projectNote:J},r,{methods:{showAll:()=>{u.value="all"},showActive:()=>{u.value="active"},showInactive:()=>{u.value="inactive"}}});i.add(()=>ee.dispose()),i.add(ne(r,{position:"bottom"}));const te=E.contextMenu(p,{cursor:"glyph",match:".usx-row",inherit:!1,density:"compact",items:e=>Z(e.dataset.id)});i.add(()=>te?.destroy?.());const L=e=>{const t=C(e);t&&(F(f,t,d.peek()).edit?S(e):E.toast("Your role is not senior to theirs, so you cannot edit this account.",{type:"info"}))},N=e=>{const t=e.target.closest(".usx-row");if(!t)return;const s=e.target.closest(".usx-kebab");if(s){Y(s);return}L(t.dataset.id)},U=e=>{const t=e.target.closest(".usx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Y(t)):e.key==="Enter"&&e.target===t&&L(t.dataset.id))};p.addEventListener("click",N),p.addEventListener("keydown",U),i.add(()=>{p.removeEventListener("click",N),p.removeEventListener("keydown",U)}),oe(a,{id:"users-new",icon:"plus",title:"New user",onClick:()=>{location.hash=l?`#/users/new?project=${encodeURIComponent(l)}`:"#/users/new"}}),I.scan(r),await h()}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.87.0",
3
+ "version": "0.88.1",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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@0335bfa",
3
+ "source": "dcms-marketplace@6301533",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.8.0",
@@ -72,37 +72,38 @@
72
72
  }
73
73
  },
74
74
  "mail-reader": {
75
- "version": "1.2.0",
75
+ "version": "1.2.1",
76
76
  "files": {
77
- "CLAUDE.md": "7f317e69b560457f2d4c401b466754850afe75f6983bc41414c71d8c276c3583",
77
+ "CLAUDE.md": "6418f9a4c56ed617962afa7c82c88ea40af060e796ceb86310bafafa79434d55",
78
78
  "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
79
79
  "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
80
80
  "plugin.js": "7f602a60c4eca7d3637659d03d5de9e242584666846b16c9e5a1b758e4856c9f",
81
- "plugin.json": "60111616d915be07c8bb53a54b8aeae906e167d157e54295bb440546128acd55"
81
+ "plugin.json": "3a22a46f3605589104d054b8f4bf5503052fa203c1d4271a837332f2fab7c56b"
82
82
  }
83
83
  },
84
84
  "security": {
85
- "version": "1.0.3",
85
+ "version": "1.1.0",
86
86
  "files": {
87
- "CLAUDE.md": "a9b7574236740de80513a535fb761127fa306f2177a5dda6e761213b1ad5b287",
87
+ "CLAUDE.md": "dc6f8755b8e6cdcac69cd928654395cde794e5b698032c132ce83dede24acead",
88
88
  "admin/lib/blocks.js": "5ef346b7de751518f8beb9212e3b89b270ae236560ae3d8a1f40d129c50918ff",
89
89
  "admin/lib/health.js": "3b233a3d29b2d69c333321602c13233b8221cfe1114aa03903739d9ae8ad65d5",
90
90
  "admin/lib/ip.js": "cf33be4fa2e2cad37bafd12ebec17f293891a88de402caad1bf9c9a57ce87680",
91
91
  "admin/lib/lockout.js": "c5e5e4b2934296f81ae8ed013725485bc269a0ef3cf3bdf3c16c53936635a1ee",
92
92
  "admin/lib/policy.js": "a4aedfd28f992e0e7759bbdac6344681eebe617c27849e91fa4405f6190f8462",
93
93
  "admin/lib/probes.js": "d56318903cb645a39f56e7762eddb7411941a72e1f446c1f9d79e35157f1d31b",
94
- "admin/lib/settings.js": "9b6c2fb3b54a1dcb642e3af6f584f93375f824fce67717a1ee56bebee9c35645",
95
- "admin/templates/security.html": "e96060b4849a1ef7691a918b9a77aff05ea3a2182a6093001454d04eb58c648d",
96
- "admin/views/security.js": "dcd38e07c5e83bbe013f5d764c52edef76d28ed4c3fe1cba8c5a59ce1b93e962",
94
+ "admin/lib/resets.js": "3724a2aec9f3c18ada96948aa6ce375ae3505bb7623a2c556e2e2670c5ca343c",
95
+ "admin/lib/settings.js": "b319953914f3a6f029ba991b36fe6fcd521956ea099578be42505f30e6c32faf",
96
+ "admin/templates/security.html": "df5798497ca1fd5ed200ce0ff921a055168ac7720f38b2db1bebf2c0efe71f04",
97
+ "admin/views/security.js": "280c1681f1aae6cf82a141e670d046c160c5f5fc3cb9de43f9d134a08b15a199",
97
98
  "bin/unlock.js": "5fcc3c0ef3ba3c424a366aa62896f51abad578183dfde283aa838a86549ca785",
98
99
  "config.js": "e6a00f7816637687d6a68ebbac65a71489246a926c28523c2a78d859e75b7ae0",
99
- "plugin.js": "3051c886f77b1b1e7f45873fbdcd78a5ba2d72caea97bde1235e84a298ab6114",
100
- "plugin.json": "bdcc88f0f5dec2d707806687ca80e1697255af49b4f30f43650e3824775deef8",
100
+ "plugin.js": "db5dc669a6a26bbe8756da59bdd04217e403be48012921b6d2b868cec57af489",
101
+ "plugin.json": "319d4333d03ed7d42ef9c2d0999befff7594343be963b6fee9c90bf7916ad142",
101
102
  "server/common-passwords.NOTICE": "3b148a49a569bfa24feaa7879db602da8f0607fa61ab6c52b7a62391fc7c009a",
102
103
  "server/common-passwords.txt": "68782d6a4a19a4768d5f15dd66bd534e7a33055cc755411e33f16d18c50fdcce",
103
104
  "server/facts.js": "25538a8d1cfe012688fceb8ec0d0dd7b43ac9e168430723d6fb731183701fb46",
104
105
  "server/state.js": "1319b322e94a4aec176948990b2d674b28dfece761eef4d5685df474037bfc4a",
105
- "tests/security.test.js": "951636a0e42ac8dbf78b69ca7e7e2adcd4e29d94a51998e738c3afa742c00866"
106
+ "tests/security.test.js": "f0cb70dcb28bc8b3de8d3b39064616296382c5b0f97bfd615ee8e24fe3b031bc"
106
107
  }
107
108
  },
108
109
  "shopping-cart": {
@@ -77,3 +77,8 @@ open on something rather than asking which before it has shown anything.
77
77
  should be checked against a **real IMAP server** — `mail.dommajs.org` answers on 993 (TLS) and 143
78
78
  (STARTTLS). A wrong-credentials attempt is enough to verify TLS, auth-failure mapping and that the
79
79
  pool leaves nothing open; it does not verify folder listing or rendering, which need a real mailbox.
80
+
81
+ ## Dependencies (1.2.1; core 0.88)
82
+
83
+ plugin.json `uses: {contacts}` - Add sender to contacts comes from core Contacts (`_lib/admin/mail/contacts.js`).
84
+ With the Tool switched off its API 404s and the action is simply absent.
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "mail-reader",
3
3
  "displayName": "Mail Reader",
4
- "version": "1.2.0",
4
+ "version": "1.2.1",
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-26",
8
+ "date": "2026-09-27",
9
9
  "icon": "mail",
10
+ "uses": {
11
+ "contacts": "Add sender to contacts"
12
+ },
10
13
  "admin": {
11
14
  "sidebar": [
12
15
  {
@@ -15,6 +15,13 @@ Ships with the engine (free tier, `make sync-free-tier`); switched ON when it fi
15
15
  MIT - see the NOTICE), not containing the name/email, not one or two characters repeated.
16
16
  - **Sign-in log**: `data/signins.jsonl`, kept `signinDays`; notifications (sources `security:lockouts`,
17
17
  `security:suspicious`, `security:probes` - the last off by default).
18
+ - **Reset requests** (1.1.0; core 0.88's `auth:resetRequested`): each "Forgot your password?" is a `kind: 'reset'`
19
+ line in the same log (`ok` = a link was sent, `reason` = the core's outcome - `admin/lib/resets.js` RESET_REASONS).
20
+ Sign-ins › Resets lists them; the overview tile counts 24 h. `security:resets` (setting `notify.resetProbing`, on) is
21
+ raised when one address asks about 3 different accounts within an hour - ALL the core allows it (forgot-password is
22
+ rate-limited to 3 an hour per address, so a higher threshold never trips) - or when 10 requests within an hour, from
23
+ any addresses, name accounts that do not exist (someone rotating addresses). Anything counting sign-ins must skip
24
+ `kind: 'reset'` lines - their `ok` is not a sign-in. Older engines never emit the event: nothing changes there.
18
25
  - **Health check**: `server/facts.js` gathers (users, engine vs npm latest, certificate via tls, security headers of the
19
26
  public home page, private paths fetched LOCALLY, TRUST_PROXY, bind, SMTP, licences); `admin/lib/health.js` judges.
20
27
  Unknown = 'info', never a pass. Cached 10 min; the sidebar badge (`/count`) = checks needing attention, red only