domma-cms 0.86.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.
- package/CLAUDE.md +9 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/http-interceptor.js +1 -1
- package/admin/js/lib/login-arrange.js +1 -0
- package/admin/js/lib/password-field.js +1 -0
- package/admin/js/lib/password-reset-actions.js +12 -0
- package/admin/js/lib/plugins-arrange.js +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -1
- package/admin/js/lib/users-arrange.js +1 -1
- package/admin/js/templates/login.html +33 -5
- package/admin/js/templates/plugins.html +7 -0
- package/admin/js/templates/settings.html +6 -0
- package/admin/js/templates/user-editor.html +7 -0
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/plugins.js +17 -7
- package/admin/js/views/settings.js +4 -4
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -2
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +4 -4
- package/plugins/mail-reader/CLAUDE.md +5 -0
- package/plugins/mail-reader/plugin.json +5 -2
- package/server/routes/api/auth.js +41 -63
- package/server/routes/api/plugins.js +33 -3
- package/server/routes/api/related.js +6 -3
- package/server/routes/api/settings.js +12 -0
- package/server/routes/api/todo.js +11 -0
- package/server/routes/api/tools.js +116 -0
- package/server/routes/api/users.js +49 -2
- package/server/server.js +27 -8
- package/server/services/notification-sources.js +82 -1
- package/server/services/passwordReset.js +507 -0
- package/server/services/plugins.js +27 -1
- package/server/services/renderer.js +5 -2
- package/server/services/tools.js +259 -0
- package/server/services/users.js +8 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
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(
|
|
12
|
-
`,
|
|
13
|
-
`,
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),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||{}}})}}};
|
package/admin/js/views/users.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
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>`,
|
|
14
|
+
</div>`,W=a=>String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),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
|
"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@
|
|
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.
|
|
75
|
+
"version": "1.2.1",
|
|
76
76
|
"files": {
|
|
77
|
-
"CLAUDE.md": "
|
|
77
|
+
"CLAUDE.md": "6418f9a4c56ed617962afa7c82c88ea40af060e796ceb86310bafafa79434d55",
|
|
78
78
|
"admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
|
|
79
79
|
"config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
|
|
80
80
|
"plugin.js": "7f602a60c4eca7d3637659d03d5de9e242584666846b16c9e5a1b758e4856c9f",
|
|
81
|
-
"plugin.json": "
|
|
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.
|
|
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-
|
|
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 {
|
|
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
|
-
|
|
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 -
|
|
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 (
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? '');
|