domma-cms 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CLAUDE.md +9 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/http-interceptor.js +1 -1
  6. package/admin/js/lib/login-arrange.js +1 -0
  7. package/admin/js/lib/password-field.js +1 -0
  8. package/admin/js/lib/password-reset-actions.js +12 -0
  9. package/admin/js/lib/plugins-arrange.js +2 -2
  10. package/admin/js/lib/site-settings-arrange.js +1 -1
  11. package/admin/js/lib/users-arrange.js +1 -1
  12. package/admin/js/templates/login.html +33 -5
  13. package/admin/js/templates/plugins.html +7 -0
  14. package/admin/js/templates/settings.html +6 -0
  15. package/admin/js/templates/user-editor.html +7 -0
  16. package/admin/js/views/login.js +2 -2
  17. package/admin/js/views/plugins.js +17 -7
  18. package/admin/js/views/settings.js +4 -4
  19. package/admin/js/views/user-editor.js +1 -1
  20. package/admin/js/views/users.js +3 -2
  21. package/package.json +1 -1
  22. package/plugins/free-tier.lock.json +4 -4
  23. package/plugins/mail-reader/CLAUDE.md +5 -0
  24. package/plugins/mail-reader/plugin.json +5 -2
  25. package/server/routes/api/auth.js +41 -63
  26. package/server/routes/api/plugins.js +33 -3
  27. package/server/routes/api/related.js +6 -3
  28. package/server/routes/api/settings.js +12 -0
  29. package/server/routes/api/tools.js +116 -0
  30. package/server/routes/api/users.js +49 -2
  31. package/server/server.js +27 -8
  32. package/server/services/notification-sources.js +21 -1
  33. package/server/services/passwordReset.js +507 -0
  34. package/server/services/plugins.js +27 -1
  35. package/server/services/renderer.js +5 -2
  36. package/server/services/tools.js +259 -0
  37. package/server/services/users.js +8 -0
@@ -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.0",
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@c07af11",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.8.0",
@@ -72,13 +72,13 @@
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": {
@@ -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
  {
@@ -7,13 +7,17 @@
7
7
  * POST /api/auth/me/avatar - upload your picture (multipart `file`); DELETE removes it
8
8
  * GET /api/auth/avatar/:name - an avatar image (public: an <img> cannot send the token)
9
9
  * POST /api/auth/login/verify - { ticket, response } → tokens, when a sign-in check asked a question (2FA)
10
+ * POST /api/auth/forgot-password - { email } → { ok, expiresIn } - emails a reset link (services/passwordReset.js)
11
+ * POST /api/auth/reset-password/check - { token, password? } → { email, expiresAt, minutesLeft, problems }
12
+ * POST /api/auth/reset-password - { token, password } → { ok } - signs the account out everywhere
10
13
  * POST /api/auth/refresh - { refreshToken } → { token } - only while its session is live
11
14
  * POST /api/auth/logout - { refreshToken } → { ok: true } - ends the session
12
15
  * GET /api/auth/sessions - your sessions; DELETE /api/auth/sessions/:sid; POST /api/auth/sessions/revoke-others
13
16
  *
14
17
  * Sign-in checks and password checks come from plugins (services/authChecks.js);
15
18
  * sessions live in services/sessions.js. Events: auth:loginFailed, auth:loginSucceeded,
16
- * auth:loggedOut, auth:passwordChanged, auth:rateLimited (and user:loggedIn, as before).
19
+ * auth:loggedOut, auth:passwordChanged, auth:rateLimited, auth:resetRequested (and user:loggedIn, as before).
20
+ * Password reset (the link, where it may point, the email) is services/passwordReset.js.
17
21
  */
18
22
  import crypto from 'node:crypto';
19
23
  import {config, getConfig} from '../../config.js';
@@ -30,8 +34,6 @@ import {
30
34
  createUser,
31
35
  getUserByEmail,
32
36
  getUserById,
33
- getUserByResetToken,
34
- setResetToken,
35
37
  touchLastLogin,
36
38
  updateUser,
37
39
  validatePassword
@@ -41,7 +43,8 @@ import {acceptedType, AVATAR_DIR, AVATAR_FILE, AVATAR_MAX_BYTES, removeAvatar, s
41
43
  import fsp from 'node:fs/promises';
42
44
  import nodePath from 'node:path';
43
45
  import {buildClearedSessionCookie, buildSessionCookie} from '../../services/viewerSession.js';
44
- import {createTransport, sendEmail} from '../../services/email.js';
46
+ import {checkResetToken, originOf, recordOrigin, requestReset, resetLifetime} from '../../services/passwordReset.js';
47
+ import {isSmtpConfigured} from '../../services/email.js';
45
48
  import {loginFailed, loginRateLimited} from '../../services/notification-sources.js';
46
49
  import {afterIndexOf, checkPassword, runLoginChecks, verifyChallenge} from '../../services/authChecks.js';
47
50
  import {createSession, isLegacyTokenRevoked, listSessions, revokeLegacyToken, revokeSession, revokeUserSessions, useSession} from '../../services/sessions.js';
@@ -67,9 +70,12 @@ const passwordProblems = (problems) => problems.join(' ');
67
70
 
68
71
  export async function authRoutes(fastify) {
69
72
  // GET /api/auth/setup-status
73
+ // `resetByEmail`: whether "Forgot your password?" can work here at all (a mail server is set up).
70
74
  fastify.get('/auth/setup-status', async () => {
71
75
  const count = await countUsers();
72
- return { needsSetup: count === 0 };
76
+ const site = getConfig('site') || {};
77
+ return {needsSetup: count === 0, siteTitle: String(site.title || ''), resetByEmail: isSmtpConfigured(site.smtp),
78
+ resetExpiresIn: resetLifetime().text};
73
79
  });
74
80
 
75
81
  // POST /api/auth/setup - create the very first admin (only allowed when no users exist)
@@ -100,6 +106,7 @@ export async function authRoutes(fastify) {
100
106
  try {
101
107
  const user = await createUser({name, email, password, role: 'super-admin'});
102
108
  const sid = await createSession({userId: user.id, ip: request.ip, ua: uaOf(request), ttlMs: parseDuration(refreshTokenExpiry)});
109
+ await recordOrigin(request);
103
110
  const {token, refreshToken} = signTokens(fastify, user, sid);
104
111
  return reply.status(201).send({token, refreshToken, user});
105
112
  } finally {
@@ -179,6 +186,7 @@ export async function authRoutes(fastify) {
179
186
  const ua = uaOf(request);
180
187
  await touchLastLogin(user.id);
181
188
  const sid = await createSession({userId: user.id, ip, ua, ttlMs: parseDuration(refreshTokenExpiry)});
189
+ await recordOrigin(request);
182
190
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
183
191
  hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
184
192
  hooks.emit('auth:loginSucceeded', {userId: user.id, email: user.email, role: user.role, ip, ua, sid});
@@ -386,86 +394,55 @@ export async function authRoutes(fastify) {
386
394
  .send(data);
387
395
  });
388
396
 
389
- // POST /api/auth/forgot-password - send a password reset email (no auth)
397
+ // POST /api/auth/forgot-password - email a reset link (no auth)
398
+ // Answers at once and the same way for every address: the lookup and the send happen
399
+ // after the reply, so neither the answer nor how long it took says whether an account exists.
390
400
  fastify.post('/auth/forgot-password', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (request) => {
391
401
  const {email} = request.body || {};
392
- if (!email) return {ok: true}; // always ok - prevents enumeration
393
-
394
- try {
395
- const user = await getUserByEmail(email);
396
- if (!user || !user.isActive) return {ok: true};
397
-
398
- const token = crypto.randomBytes(32).toString('hex');
399
- const hash = crypto.createHash('sha256').update(token).digest('hex');
400
-
401
- const authCfg = getConfig('auth');
402
- const expiryMs = parseDuration(authCfg.resetTokenExpiry || '1h');
403
- const expiry = new Date(Date.now() + expiryMs).toISOString();
404
-
405
- await setResetToken(user.id, hash, expiry);
406
-
407
- const siteConfig = getConfig('site');
408
- const smtp = siteConfig.smtp || {};
409
- const sitePort = config.server.port || 4096;
410
- const resetUrl = `${request.protocol}://${request.hostname}:${sitePort}/admin/#/reset-password?token=${token}`;
411
-
412
- const transport = await createTransport(smtp);
413
- const from = smtp.fromAddress || 'noreply@example.com';
414
- const fromName = smtp.fromName || siteConfig.title || 'Domma CMS';
415
-
416
- await sendEmail(transport, {
417
- from,
418
- fromName,
419
- to: user.email,
420
- subject: 'Reset your password',
421
- html: `
422
- <!DOCTYPE html>
423
- <html>
424
- <body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
425
- <h2 style="color:#333;">Password Reset</h2>
426
- <p>Hi ${user.name},</p>
427
- <p>We received a request to reset your password. Click the link below - it expires in 1 hour.</p>
428
- <p style="margin:24px 0;">
429
- <a href="${resetUrl}" style="background:#5b8cff;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Reset Password</a>
430
- </p>
431
- <p style="color:#888;font-size:.85rem;">If you didn't request this, you can safely ignore this email.</p>
432
- <p style="color:#bbb;font-size:.8rem;">${resetUrl}</p>
433
- </body>
434
- </html>`.trim(),
435
- text: `Hi ${user.name},\n\nReset your password here:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, ignore this email.`
436
- });
437
- } catch (err) {
438
- console.error('[auth] forgot-password error:', err.message);
402
+ if (typeof email === 'string' && email.trim() && email.length <= 320) {
403
+ const job = {email, origin: originOf(request), ip: request.ip, ua: uaOf(request), log: fastify.log};
404
+ setImmediate(() => { requestReset(job).catch(() => {}); });
439
405
  }
406
+ return {ok: true, expiresIn: resetLifetime().text};
407
+ });
440
408
 
441
- return {ok: true};
409
+ // POST /api/auth/reset-password/check - {token, password?}: is the link still good (the screen asks on
410
+ // opening, so an expired link says so before anything is typed), and - with a password - what the
411
+ // site's password rules make of it, as it is typed. Only ever for the holder of a live link.
412
+ fastify.post('/auth/reset-password/check', {config: {rateLimit: {max: 60, timeWindow: '1 minute'}}}, async (request, reply) => {
413
+ const {token, password} = request.body || {};
414
+ const user = await checkResetToken(token);
415
+ if (!user) return reply.status(400).send({error: 'Invalid or expired reset link', expired: true});
416
+ let problems = [];
417
+ if (typeof password === 'string' && password) {
418
+ problems = password.length < 8 ? ['Password must be at least 8 characters.']
419
+ : await checkPassword(password, {email: user.email, name: user.name, userId: user.id, request}, {log: fastify.log});
420
+ }
421
+ const ms = Date.parse(user.resetTokenExpiry) - Date.now();
422
+ return {email: user.email, expiresAt: user.resetTokenExpiry, minutesLeft: Math.max(1, Math.ceil(ms / 60_000)), problems};
442
423
  });
443
424
 
444
425
  // POST /api/auth/reset-password - consume token and set new password (no auth)
445
426
  fastify.post('/auth/reset-password', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
446
427
  const {token, password} = request.body || {};
447
- if (!token || !password) {
428
+ if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
448
429
  return reply.status(400).send({error: 'token and password are required'});
449
430
  }
450
431
  if (password.length < 8) {
451
432
  return reply.status(400).send({error: 'Password must be at least 8 characters'});
452
433
  }
453
434
 
454
- const hash = crypto.createHash('sha256').update(token).digest('hex');
455
- const user = await getUserByResetToken(hash);
435
+ const user = await checkResetToken(token);
456
436
  if (!user) {
457
- return reply.status(400).send({error: 'Invalid or expired reset link'});
458
- }
459
- if (new Date(user.resetTokenExpiry) < new Date()) {
460
- await clearResetToken(user.id);
461
- return reply.status(400).send({error: 'Invalid or expired reset link'});
437
+ return reply.status(400).send({error: 'Invalid or expired reset link', expired: true});
462
438
  }
463
439
 
464
440
  const problems = await checkPassword(password, {email: user.email, name: user.name, userId: user.id, request}, {log: fastify.log});
465
441
  if (problems.length) return reply.status(400).send({error: passwordProblems(problems), problems});
466
442
 
467
- await updateUser(user.id, {password});
443
+ // Used up BEFORE the password is set: two submissions at once must not both get through.
468
444
  await clearResetToken(user.id);
445
+ await updateUser(user.id, {password});
469
446
  await revokeUserSessions(user.id);
470
447
  hooks.emit('auth:passwordChanged', {userId: user.id, by: 'reset', ip: request.ip, ua: uaOf(request)});
471
448
  return {ok: true};
@@ -520,6 +497,7 @@ export async function authRoutes(fastify) {
520
497
  return reply.status(401).send({ error: 'User not found or inactive' });
521
498
  }
522
499
 
500
+ await recordOrigin(request);
523
501
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
524
502
  const token = fastify.jwt.sign({ ...safeUser, type: 'access', ...(payload.sid && {sid: payload.sid}) }, { expiresIn: accessTokenExpiry });
525
503
  setSessionCookie(request, reply, token);
@@ -23,6 +23,8 @@ import {
23
23
  savePluginState
24
24
  } from '../../services/plugins.js';
25
25
  import {getEffectiveLevel} from '../../services/userRoles.js';
26
+ import {getToolStates, planSwitch, requiresOf, switchableUnits, usesOf} from '../../services/tools.js';
27
+ import {applyCascade} from './tools.js';
26
28
  import {scaffoldPlugin} from '../../services/pluginScaffold.js';
27
29
  import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
28
30
  import {getConfig} from '../../config.js';
@@ -83,14 +85,14 @@ let _restartTimer = null;
83
85
  * @param {number} [delayMs]
84
86
  * @returns {void}
85
87
  */
86
- function scheduleRestart(exit, delayMs = 1500) {
88
+ export function scheduleRestart(exit, delayMs = 1500) {
87
89
  if (_restartTimer) clearTimeout(_restartTimer);
88
90
  _restartTimer = setTimeout(exit, delayMs);
89
91
  _restartTimer.unref?.();
90
92
  }
91
93
 
92
94
  /** Admins can opt out of automatic restarts in config/server.json. */
93
- function autoRestartEnabled() {
95
+ export function autoRestartEnabled() {
94
96
  try {
95
97
  return getConfig('server')?.restartOnPluginToggle !== false;
96
98
  } catch {
@@ -251,6 +253,13 @@ export async function pluginsRoutes(fastify, opts = {}) {
251
253
  // Enabled but displaced by another plugin that replaces it. Shown
252
254
  // so the screen can explain the inactivity instead of looking broken.
253
255
  supersededBy: loadedPlugins[manifest.name]?.supersededBy ?? null,
256
+ // Enabled but not running because something it requires is not
257
+ // (a built-in Tool switched off, a required plugin that is off).
258
+ blockedBy: loadedPlugins[manifest.name]?.blockedBy ?? null,
259
+ // The site's manager holds this switch: the admin here cannot move it.
260
+ locked: states[manifest.name]?.locked === true,
261
+ requires: requiresOf(manifest),
262
+ uses: usesOf(manifest),
254
263
  settings: maskSettings(states[manifest.name]?.settings || {}, '', schemaSecrets(manifest)),
255
264
  // What Configure edits: an optional plugin.json `settingsSchema`
256
265
  // ([{key, label, type: text|number|select|boolean, options, min,
@@ -311,6 +320,27 @@ export async function pluginsRoutes(fastify, opts = {}) {
311
320
  }
312
321
 
313
322
  const prevState = getPluginStates()[name] || {};
323
+
324
+ // The switch goes through the same planner the Manager uses: a lock
325
+ // refuses it, and anything it drags along (a plugin that requires this
326
+ // one going off, a requirement coming on) is answered as a plan to
327
+ // confirm - `cascade: true` on the repeat applies it.
328
+ let cascaded = [];
329
+ if (enabled !== undefined && !!enabled !== !!prevState.enabled) {
330
+ const plan = planSwitch(switchableUnits({manifests, pluginStates: getPluginStates(), toolStates: getToolStates()}),
331
+ {[name]: !!enabled});
332
+ if (!plan.ok) {
333
+ const r = plan.refused[0];
334
+ const who = r.name === name ? (manifest.displayName || name) : r.name;
335
+ return reply.status(r.reason.includes('locked') ? 423 : 409).send({error: `${who} ${r.reason}.`, plan});
336
+ }
337
+ cascaded = plan.cascaded;
338
+ if ((cascaded.length || plan.losses.length) && request.body?.cascade !== true) {
339
+ return reply.status(409).send({error: 'This switch changes other Tools too.', needsConfirm: true, plan});
340
+ }
341
+ await applyCascade(cascaded, fastify);
342
+ }
343
+
314
344
  const update = {};
315
345
  if (enabled !== undefined) update.enabled = !!enabled;
316
346
  // A secret sent back as the mask keeps its stored value (see maskSettings).
@@ -329,7 +359,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
329
359
  // but only where a supervisor will bring the process back, and only
330
360
  // when the enabled flag actually changed (a settings-only save must not
331
361
  // bounce the site).
332
- const toggled = enabled !== undefined && !!enabled !== !!prevState.enabled;
362
+ const toggled = (enabled !== undefined && !!enabled !== !!prevState.enabled) || cascaded.length > 0;
333
363
  const supervised = hasSupervisor();
334
364
  const restarting = toggled && supervised && autoRestartEnabled();
335
365
  if (restarting) scheduleRestart(exit);
@@ -17,11 +17,12 @@ import {getEntry} from '../../services/collections.js';
17
17
  import {getRelatedProviders, RELATED_KINDS} from '../../services/hooks.js';
18
18
  import {getPermissionsFor} from '../../services/roles.js';
19
19
  import {getEffectiveRoles} from '../../services/userRoles.js';
20
+ import {isToolEnabled} from '../../services/tools.js';
20
21
 
21
22
  /** Each kind: its Tool's permission resource and its collection. */
22
23
  const KINDS = {
23
- contact: {resource: 'contacts', slug: 'contacts-contacts'},
24
- note: {resource: 'notes', slug: 'notes-notes'}
24
+ contact: {resource: 'contacts', slug: 'contacts-contacts', tool: 'contacts'},
25
+ note: {resource: 'notes', slug: 'notes-notes', tool: 'notes'}
25
26
  };
26
27
 
27
28
  /** How long one provider may take before it is left out of the answer. */
@@ -43,7 +44,9 @@ export async function relatedRoutes(fastify, opts = {}) {
43
44
  const roles = getEffectiveRoles(request.user);
44
45
  return {id: request.user?.id ?? request.user?.sub ?? null, role: request.user?.role ?? null, roles};
45
46
  };
46
- const mayRead = (user, kind) => user.roles.some(r => getPermissionsFor(KINDS[kind].resource, 'read').includes(r));
47
+ // A kind whose Tool is switched off has nothing to relate to (403, as for a role without it).
48
+ const mayRead = (user, kind) => isToolEnabled(KINDS[kind].tool)
49
+ && user.roles.some(r => getPermissionsFor(KINDS[kind].resource, 'read').includes(r));
47
50
 
48
51
  fastify.get('/providers', {preHandler: [authenticate]}, async (request, reply) => {
49
52
  const kind = String(request.query?.kind ?? '');