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.
- 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/tools.js +116 -0
- package/server/routes/api/users.js +49 -2
- package/server/server.js +27 -8
- package/server/services/notification-sources.js +21 -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,2 +1,2 @@
|
|
|
1
|
-
export const FILTERS=["all","on","off","attention"],BROWSE_FILTERS=["all","installed","available","updates"],BLOCKING=new Set(["expired","unlicensed"]);const c={grace:{text:"Renew",tone:"warn",title:"The rental term has ended - still running during the grace period"},"support-ended":{text:"Support ended",tone:"muted",title:"Owned outright and still running, but no longer entitled to updates"},expired:{text:"Expired",tone:"danger",title:"The rental term and grace period have ended - this plugin is not running"},unlicensed:{text:"Unlicensed",tone:"danger",title:"No valid entitlement for this site - this plugin is not running"}};export function compareVersions(e,
|
|
2
|
-
`)||
|
|
1
|
+
export const FILTERS=["all","on","off","attention"],BROWSE_FILTERS=["all","installed","available","updates"],BLOCKING=new Set(["expired","unlicensed"]);const c={grace:{text:"Renew",tone:"warn",title:"The rental term has ended - still running during the grace period"},"support-ended":{text:"Support ended",tone:"muted",title:"Owned outright and still running, but no longer entitled to updates"},expired:{text:"Expired",tone:"danger",title:"The rental term and grace period have ended - this plugin is not running"},unlicensed:{text:"Unlicensed",tone:"danger",title:"No valid entitlement for this site - this plugin is not running"}};export function compareVersions(e,o){const r=i=>String(i??"").split(".").map(a=>parseInt(a,10)||0),t=r(e),n=r(o);for(let i=0;i<Math.max(t.length,n.length);i++){const a=(t[i]??0)-(n[i]??0);if(a)return a>0?1:-1}return 0}export function pluginStatus(e){return e.core?{key:"core",text:"Built in",tone:"info",running:!0}:e.enabled&&BLOCKING.has(e.entitlement)?{key:"blocked",text:"Not running",tone:"danger",running:!1}:e.enabled&&e.supersededBy?{key:"replaced",text:"Replaced",tone:"muted",running:!1}:e.enabled&&e.blockedBy?{key:"blocked",text:`Needs ${e.blockedBy}`,tone:"danger",running:!1}:e.enabled?{key:"on",text:"On",tone:"ok",running:!0}:{key:"off",text:"Off",tone:"muted",running:!1}}export function updateFor(e,o){const r=o?.get?.(e.name);return r&&compareVersions(r,e.version)>0?r:""}export function needsAttention(e,o){return pluginStatus(e).key==="blocked"||e.entitlement==="grace"||!!updateFor(e,o)}export function fmtReleased(e){if(!e||!/^\d{4}-\d{2}-\d{2}/.test(String(e)))return"";const o=new Date(e);if(Number.isNaN(o.getTime()))return"";const r=t=>String(t).padStart(2,"0");return`${r(o.getDate())}.${r(o.getMonth()+1)}.${o.getFullYear()}`}export function arrangePlugins(e,{filter:o="all",query:r="",latest:t=new Map}={}){const n=String(r).trim().toLowerCase(),i={all:()=>!0,on:s=>pluginStatus(s).running,off:s=>!pluginStatus(s).running,attention:s=>needsAttention(s,t)}[o]||(()=>!0),a=s=>needsAttention(s,t)?0:pluginStatus(s).running?1:2;return[...e||[]].filter(i).filter(s=>!n||[s.name,s.displayName,s.description,s.author].some(l=>String(l||"").toLowerCase().includes(n))).sort((s,l)=>a(s)-a(l)||String(s.displayName||s.name).localeCompare(String(l.displayName||l.name)))}export function pluginRow(e,o=new Map){const r=pluginStatus(e),t=c[e.entitlement]||null,n=updateFor(e,o),i=fmtReleased(e.date);return{name:e.name,displayName:e.displayName||e.name,icon:e.icon||"package",meta:[`v${e.version}`,e.author?`by ${e.author}`:"",i?`released ${i}`:"",e.supersededBy?`replaced by ${e.supersededBy}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),description:e.description||"",statusText:r.text,statusCls:`is-${r.tone}`,licensed:e.closedSource===!0,entText:t?.text||"",entCls:t?`is-${t.tone}`:"",entTitle:t?.title||"",update:n,updateText:n?`Update to ${n}`:"",configurable:settingsFields(e).length>0,locked:e.locked===!0,rowCls:[r.running?"":"is-off",needsAttention(e,o)?"is-attention":""].filter(Boolean).join(" ")}}export function formatPrice(e){if(e?.tier!=="paid")return"Free";const o=e.terms?.managed?.monthly;return o==null||!Number.isFinite(Number(o))?"Paid":`${e.terms?.currency==="GBP"?"\xA3":`${e.terms?.currency||""} `}${(Number(o)/100).toFixed(2)}/mo`}export function catalogueAction(e){return e.installed&&e.updatable&&e.entitled?"update":e.installed?"installed":e.entitled?"install":"locked"}export function arrangeCatalogue(e,{filter:o="all",query:r=""}={}){const t=String(r).trim().toLowerCase(),n={all:()=>!0,installed:a=>a.installed,available:a=>!a.installed&&a.entitled,updates:a=>catalogueAction(a)==="update"}[o]||(()=>!0),i={update:0,install:1,installed:2,locked:3};return[...e||[]].filter(n).filter(a=>!t||[a.slug,a.displayName,a.description].some(s=>String(s||"").toLowerCase().includes(t))).sort((a,s)=>i[catalogueAction(a)]-i[catalogueAction(s)]||String(a.displayName||a.slug).localeCompare(String(s.displayName||s.slug)))}export function catalogueRow(e){const o=catalogueAction(e),r=e.entitled&&e.tier==="paid";return{slug:e.slug,displayName:e.displayName||e.slug,icon:e.icon||"package",meta:[e.installed&&e.installedVersion&&e.installedVersion!==e.version?`v${e.installedVersion} \u2192 v${e.version}`:`v${e.version}`,e.installed?"installed":""].filter(Boolean).join(" \xB7 "),description:e.description||"",price:formatPrice(e),licText:r?e.licenceStatus==="grace"?"Renew":"Licensed":"",licCls:e.licenceStatus==="grace"?"is-warn":"is-ok",action:o,actionText:{update:"Update",install:"Install",installed:"Installed",locked:e.licenceStatus==="expired"?"Licence expired":"Not licensed"}[o],rowCls:o==="locked"?"is-off":o==="update"?"is-attention":""}}export const isSecretKey=e=>/pass(word)?|secret|token|api[-_]?key|private|credential|uri$|dsn/i.test(String(e));export function labelFor(e){const o=String(e).replace(/[_-]+/g," ").replace(/([a-z0-9])([A-Z])/g,"$1 $2").trim().toLowerCase();return o?o[0].toUpperCase()+o.slice(1):String(e)}export function settingsFields(e){const o=e?.settings||{},r={...e?.effectiveSettings||{},...o};return Array.isArray(e?.settingsSchema)&&e.settingsSchema.length?e.settingsSchema.filter(t=>t&&t.key).map(t=>{const n=t.type==="select"?"select":t.type==="number"?"number":t.type==="boolean"||t.type==="checkbox"?"checkbox":t.type==="textarea"?"textarea":"text",i=r[t.key]??t.default??(n==="checkbox"?!1:"");return{key:t.key,label:t.label||labelFor(t.key),type:n,value:n==="checkbox"?i===!0:String(i??""),options:(t.options||[]).map(a=>typeof a=="object"?{value:String(a.value),label:String(a.label??a.value)}:{value:String(a),label:String(a)}),min:t.min,max:t.max,help:t.help||"",secret:t.secret===!0||isSecretKey(t.key)}}):Object.entries(r).map(([t,n])=>{const i=isSecretKey(t);if(typeof n=="boolean")return{key:t,label:labelFor(t),type:"checkbox",value:n,secret:i};if(typeof n=="number")return{key:t,label:labelFor(t),type:"number",value:String(n),secret:i};if(n!==null&&typeof n=="object")return{key:t,label:labelFor(t),type:"json",value:JSON.stringify(n,null,2),secret:i};const a=String(n??"");return{key:t,label:labelFor(t),type:a.includes(`
|
|
2
|
+
`)||a.length>80?"textarea":"text",value:a,secret:i}})}export function readSettings(e,o){const r={},t={};for(const n of e){const i=o[n.key];if(n.type==="checkbox"){!!i!=!!n.value&&(t[n.key]=!!i);continue}const a=String(i??"");if(a!==String(n.value))if(n.type==="number"){const s=Number(a);if(a.trim()===""||!Number.isFinite(s)){r[n.key]="A number, please.";continue}if(n.min!=null&&s<n.min){r[n.key]=`At least ${n.min}.`;continue}if(n.max!=null&&s>n.max){r[n.key]=`At most ${n.max}.`;continue}t[n.key]=s}else if(n.type==="json")try{t[n.key]=JSON.parse(a)}catch{r[n.key]="Not valid JSON."}else t[n.key]=a}return{errors:r,changed:t}}export function slugifyPlugin(e){return String(e??"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^[^a-z]+/,"").replace(/-+$/g,"").slice(0,40).replace(/-+$/,"")}export function checkScaffold(e,o=[]){const r=a=>String(e?.[a]??"").trim(),t={displayName:r("displayName"),slug:r("slug"),description:r("description"),author:r("author"),icon:r("icon")||"box"},n={},i=/[<>"\\]|\*\//;t.displayName?t.displayName.length>60&&(n.displayName="60 characters at most."):n.displayName="A plugin needs a name.",/^[a-z][a-z0-9-]{1,39}$/.test(t.slug)?o.includes(t.slug)&&(n.slug=`"${t.slug}" is already installed.`):n.slug="2-40 characters: lowercase letters, digits and hyphens, starting with a letter.",t.description.length>300&&(n.description="300 characters at most."),t.author.length>80&&(n.author="80 characters at most."),/^[a-z][a-z0-9-]{0,39}$/.test(t.icon)||(n.icon="A lowercase icon name (letters, digits, hyphens).");for(const a of["displayName","description","author"])!n[a]&&i.test(t[a])&&(n[a]='No <, >, ", backslash or */.');return{errors:n,payload:t}}export function planLines(e){const o=[];for(const r of e?.cascaded||[])o.push(`${r.displayName||r.name} is switched ${r.enabled?"on":"off"} too - ${r.enabled?`${r.becauseName||r.because} needs it`:`it needs ${r.becauseName||r.because}`}.`);for(const r of e?.losses||[])o.push(`${r.displayName||r.name} stays on without ${r.toolName||r.tool}${r.reason?`, but loses: ${r.reason.replace(/\.$/,"")}`:""}.`);return o}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TAB_FIELDS={general:["title","tagline","adminHome","spacerSize"],seo:["seoTitle","seoSeparator","seoDescription"],footer:["footerCopyright","footerMotionToggle","socialTwitter","socialFacebook","socialInstagram","socialLinkedin","socialGithub","socialYoutube"],email:["smtpHost","smtpPort","smtpUser","smtpPass","smtpSecure","smtpFromAddress","smtpFromName"],backToTop:["bttEnabled","bttSmooth","bttLabel","bttThreshold","bttPosition","bttOffset","bttBottomOffset"],cookies:["ccEnabled","ccMessage","ccAcceptAll","ccRejectAll","ccCustomize","ccSavePrefs","ccPrivacyText","ccPrivacyUrl","ccCookieText","ccCookieUrl","ccPosition","ccLayout","ccTheme","ccShowFunctional","ccShowAnalytics","ccShowMarketing","ccVersion"],breadcrumbs:["bcEnabled","bcHomeLabel","bcPosition","bcOffsetX","bcOffsetY"],css:["css"]},SECRET_FIELDS=["smtpPass"],BREADCRUMB_POSITIONS=["TL","TR","BL","BR"];const
|
|
1
|
+
export const TAB_FIELDS={general:["title","tagline","siteUrl","adminHome","spacerSize"],seo:["seoTitle","seoSeparator","seoDescription"],footer:["footerCopyright","footerMotionToggle","socialTwitter","socialFacebook","socialInstagram","socialLinkedin","socialGithub","socialYoutube"],email:["smtpHost","smtpPort","smtpUser","smtpPass","smtpSecure","smtpFromAddress","smtpFromName"],backToTop:["bttEnabled","bttSmooth","bttLabel","bttThreshold","bttPosition","bttOffset","bttBottomOffset"],cookies:["ccEnabled","ccMessage","ccAcceptAll","ccRejectAll","ccCustomize","ccSavePrefs","ccPrivacyText","ccPrivacyUrl","ccCookieText","ccCookieUrl","ccPosition","ccLayout","ccTheme","ccShowFunctional","ccShowAnalytics","ccShowMarketing","ccVersion"],breadcrumbs:["bcEnabled","bcHomeLabel","bcPosition","bcOffsetX","bcOffsetY"],css:["css"]},SECRET_FIELDS=["smtpPass"],BREADCRUMB_POSITIONS=["TL","TR","BL","BR"];const e=(t,c="")=>t==null?c:String(t),i=(t,c)=>{const s=Number.parseInt(t,10);return Number.isFinite(s)?s:c};export function homeValue(t){return/^\/[A-Za-z0-9\-_/]*$/.test(t||"")&&String(t).replace(/\/+$/,"")||"/"}export function formFromSettings(t={},c=""){const s=t.backToTop||{},o=t.cookieConsent||{},r=t.breadcrumbs||{};return{title:e(t.title),tagline:e(t.tagline),siteUrl:e(t.baseUrl),adminHome:homeValue(t.adminHome),spacerSize:e(t.layoutOptions?.spacerSize??40),seoTitle:e(t.seo?.defaultTitle),seoSeparator:e(t.seo?.titleSeparator," | "),seoDescription:e(t.seo?.defaultDescription),footerCopyright:e(t.footer?.copyright),footerMotionToggle:t.footer?.showMotionToggle!==!1,socialTwitter:e(t.social?.twitter),socialFacebook:e(t.social?.facebook),socialInstagram:e(t.social?.instagram),socialLinkedin:e(t.social?.linkedin),socialGithub:e(t.social?.github),socialYoutube:e(t.social?.youtube),smtpHost:e(t.smtp?.host),smtpPort:e(t.smtp?.port||587),smtpUser:e(t.smtp?.user),smtpPass:"",smtpSecure:t.smtp?.secure===!0,smtpFromAddress:e(t.smtp?.fromAddress),smtpFromName:e(t.smtp?.fromName),bttEnabled:s.enabled!==!1,bttSmooth:s.smooth!==!1,bttLabel:e(s.label),bttThreshold:e(s.scrollThreshold??300),bttPosition:e(s.position,"bottom-right"),bttOffset:e(s.offset??32),bttBottomOffset:e(s.bottomOffset??s.offset??32),ccEnabled:o.enabled!==!1,ccMessage:e(o.message),ccAcceptAll:e(o.acceptAllText,"Accept All"),ccRejectAll:e(o.rejectAllText,"Reject All"),ccCustomize:e(o.customizeText,"Customize"),ccSavePrefs:e(o.savePreferencesText,"Save Preferences"),ccPrivacyText:e(o.privacyPolicyText,"Privacy Policy"),ccPrivacyUrl:e(o.privacyPolicyUrl),ccCookieText:e(o.cookiePolicyText,"Cookie Policy"),ccCookieUrl:e(o.cookiePolicyUrl),ccPosition:e(o.position,"bottom"),ccLayout:e(o.layout,"bar"),ccTheme:e(o.theme,"dark"),ccShowFunctional:o.showFunctional!==!1,ccShowAnalytics:o.showAnalytics!==!1,ccShowMarketing:o.showMarketing!==!1,ccVersion:e(o.consentVersion,"1.0"),bcEnabled:r.enabled===!0,bcHomeLabel:e(r.homeLabel,"Home"),bcPosition:BREADCRUMB_POSITIONS.includes(r.position)?r.position:"TL",bcOffsetX:e(r.offsetX??8),bcOffsetY:e(r.offsetY??8),css:e(c)}}export function payloadFromForm(t,c={},{adminHomeReady:s=!0}={}){const o=r=>e(r).trim();return{title:o(t.title),tagline:o(t.tagline),baseUrl:o(t.siteUrl).replace(/\/+$/,""),...s&&{adminHome:o(t.adminHome)||"/"},layoutOptions:{spacerSize:i(t.spacerSize,40)||40},seo:{defaultTitle:o(t.seoTitle),titleSeparator:e(t.seoSeparator)||" | ",defaultDescription:o(t.seoDescription)},footer:{copyright:o(t.footerCopyright),showMotionToggle:t.footerMotionToggle===!0,links:c.footer?.links||[]},social:{twitter:o(t.socialTwitter),facebook:o(t.socialFacebook),instagram:o(t.socialInstagram),linkedin:o(t.socialLinkedin),github:o(t.socialGithub),youtube:o(t.socialYoutube)},smtp:{host:o(t.smtpHost),port:i(t.smtpPort,587)||587,user:o(t.smtpUser),pass:e(t.smtpPass),secure:t.smtpSecure===!0,fromAddress:o(t.smtpFromAddress),fromName:o(t.smtpFromName)},backToTop:{enabled:t.bttEnabled===!0,scrollThreshold:i(t.bttThreshold,300),position:e(t.bttPosition)||"bottom-right",offset:i(t.bttOffset,32),bottomOffset:i(t.bttBottomOffset,32),label:o(t.bttLabel),smooth:t.bttSmooth===!0},cookieConsent:{enabled:t.ccEnabled===!0,message:o(t.ccMessage),acceptAllText:o(t.ccAcceptAll),rejectAllText:o(t.ccRejectAll),customizeText:o(t.ccCustomize),savePreferencesText:o(t.ccSavePrefs),privacyPolicyText:o(t.ccPrivacyText),privacyPolicyUrl:o(t.ccPrivacyUrl),cookiePolicyText:o(t.ccCookieText),cookiePolicyUrl:o(t.ccCookieUrl),position:e(t.ccPosition)||"bottom",layout:e(t.ccLayout)||"bar",theme:e(t.ccTheme)||"dark",showFunctional:t.ccShowFunctional===!0,showAnalytics:t.ccShowAnalytics===!0,showMarketing:t.ccShowMarketing===!0,consentVersion:o(t.ccVersion)||"1.0"},breadcrumbs:{enabled:t.bcEnabled===!0,homeLabel:o(t.bcHomeLabel)||"Home",position:BREADCRUMB_POSITIONS.includes(t.bcPosition)?t.bcPosition:"TL",offsetX:i(t.bcOffsetX,8),offsetY:i(t.bcOffsetY,8)}}}export function siteUrlError(t){const c=e(t).trim().replace(/\/+$/,"");return c?/^https?:\/\//i.test(c)?/^https?:\/\/([a-z0-9.\-]+|\[[0-9a-f:.]+\])(:\d{1,5})?$/i.test(c)?"":"Just the address, like https://example.com - no page path after it.":"Start with https:// (or http://).":""}const a=t=>typeof t=="boolean"?t:e(t);export function snapshot(t){return JSON.stringify(Object.keys(t).sort().map(c=>[c,a(t[c])]))}export function changedTabs(t,c){const s=[];for(const[o,r]of Object.entries(TAB_FIELDS))r.some(n=>a(t[n])!==a(c[n]))&&s.push(o);return s}export function draftOf(t){return Object.fromEntries(Object.entries(t).filter(([c])=>!SECRET_FIELDS.includes(c)))}export function settingsProblems(t){const c=[];return e(t.smtpHost).trim()?e(t.smtpFromAddress).trim()||c.push({tone:"warn",tab:"email",text:"No From address - mail has no sender and many servers refuse it."}):c.push({tone:"danger",tab:"email",text:"No SMTP host - mail goes to a throwaway test inbox and reaches no one (password resets, form notifications)."}),t.ccEnabled===!0&&!e(t.ccPrivacyUrl).trim()&&c.push({tone:"warn",tab:"cookies",text:"The cookie banner is on but links to no privacy policy."}),c}export function describeCacheKey(t){const c=/^page:(.*?):m\d+:role([^:]*):/.exec(String(t||""));return c?{path:c[1]||"/",role:c[2]||"anon",page:!0}:{path:String(t||""),role:"",page:!1}}export function expiresIn(t,c=Date.now()){if(t==null)return"no expiry";const s=t-c;return s<=0?"expired":s<6e4?`${Math.round(s/1e3)}s`:s<36e5?`${Math.round(s/6e4)}m`:`${Math.round(s/36e5)}h`}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const FILTERS=["all","active","inactive"],SORTS=["name","recent"];const
|
|
1
|
+
export const FILTERS=["all","active","inactive"],SORTS=["name","recent"];const d={"badge-danger":"danger","badge-warning":"warn","badge-success":"ok","badge-primary":"primary","badge-info":"primary"};export function roleIndex(e=[]){const t={};for(const o of e||[]){const n=o?.data||o||{};n.name&&(t[n.name]={label:n.label||n.name,level:Number.isFinite(n.level)?n.level:99,tone:d[n.badgeClass]||""})}return t}const f=(e,t)=>e[t]?.level??99;export function canManage(e,t,o={}){const n=!!e?.id&&e.id===t?.id,l=f(o,e?.role)<f(o,t?.role);return{self:n,edit:n||l,other:!n&&l}}export function resetAllowed(e,t,o={}){const n=canManage(e,t,o);return n.self?{ok:!1,why:"change your own in My Profile"}:n.other?t?.isActive===!1?{ok:!1,why:"make the account active first"}:{ok:!0,why:""}:{ok:!1,why:"your role is not senior to theirs"}}export function initials(e){const t=n=>(String(n).match(/[\p{L}\p{N}]/u)||[""])[0],o=String(e?.name||"").split(/\s+/).map(t).filter(Boolean);return o.length?(o[0]+(o.length>1?o.at(-1):"")).toUpperCase():(t(e?.email||"")||"?").toUpperCase()}export function arrangeUsers(e,{filter:t="all",role:o="",query:n="",sort:l="name"}={}){const a=String(n).trim().toLowerCase(),s=(e||[]).filter(i=>t==="active"&&i.isActive===!1||t==="inactive"&&i.isActive!==!1||o&&i.role!==o&&!(Array.isArray(i.additionalRoles)&&i.additionalRoles.includes(o))?!1:a?[i.name,i.email,i.role,...i.additionalRoles||[],...i.projects||[]].some(r=>String(r||"").toLowerCase().includes(a)):!0),c=(i,r)=>String(i.name||i.email||"").localeCompare(String(r.name||r.email||""));return l==="recent"?s.sort((i,r)=>(i.lastLogin?0:1)-(r.lastLogin?0:1)||String(r.lastLogin||"").localeCompare(String(i.lastLogin||""))||c(i,r)):s.sort(c)}export function userRow(e,{roles:t={},me:o=null,when:n=a=>a,now:l=Date.now()}={}){const a=canManage(o,e,t),s=t[e.role]||{label:e.role||"no role",tone:""},c=(Array.isArray(e.additionalRoles)?e.additionalRoles:[]).filter(r=>r&&r!==e.role).map(r=>t[r]?.label||r),i=Array.isArray(e.projects)&&e.projects.length?e.projects:[];return{id:e.id,name:e.name||e.email||"Unnamed",initials:initials(e),meta:[e.email,e.lastLogin?`signed in ${n(e.lastLogin)}`:"never signed in"].filter(Boolean).join(" \xB7 "),roleLabel:s.label,roleTone:s.tone?`is-${s.tone}`:"",extraRoles:c.join(", "),scope:i.length?`Access: ${i.join(", ")}`:"",inactive:e.isActive===!1,resetOpen:!!e.resetLinkExpires&&Date.parse(e.resetLinkExpires)>l,self:a.self,locked:!a.edit,rowCls:[e.isActive===!1&&"is-off",!a.edit&&"is-locked"].filter(Boolean).join(" ")}}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<div class="card-body">
|
|
4
4
|
<div class="login-logo">
|
|
5
5
|
<span data-icon="layout"></span>
|
|
6
|
-
<h1>Domma CMS</h1>
|
|
6
|
+
<h1 id="login-site-title">Domma CMS</h1>
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
9
|
<!-- ── Step 1: Setup (first-time - no users exist) ── -->
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
<!-- ── Login mode ── -->
|
|
52
52
|
<div id="login-panel" style="display:none">
|
|
53
53
|
<h2 class="login-heading">Sign in</h2>
|
|
54
|
+
<div id="login-notice" class="alert alert-info mb-3" style="display:none"></div>
|
|
54
55
|
<div id="login-form-container"></div>
|
|
55
56
|
<a href="#" id="forgot-link" class="btn-skip">Forgot your password?</a>
|
|
56
57
|
</div>
|
|
@@ -82,8 +83,12 @@
|
|
|
82
83
|
<!-- ── Forgot password ── -->
|
|
83
84
|
<div id="forgot-panel" style="display:none">
|
|
84
85
|
<h2 class="login-heading">Reset your password</h2>
|
|
85
|
-
<p class="text-muted mb-4">Enter your email and we'll send you a reset link.</p>
|
|
86
|
+
<p class="text-muted mb-4" id="forgot-intro">Enter your email and we'll send you a reset link.</p>
|
|
86
87
|
<div id="forgot-form-container"></div>
|
|
88
|
+
<div id="forgot-unavailable" class="alert alert-warning mb-3" style="display:none">
|
|
89
|
+
This site cannot send email, so it cannot send you a reset link. Ask an administrator to set a
|
|
90
|
+
new password for you.
|
|
91
|
+
</div>
|
|
87
92
|
<a href="#" id="forgot-back-link" class="btn-skip">Back to sign in</a>
|
|
88
93
|
</div>
|
|
89
94
|
|
|
@@ -92,15 +97,38 @@
|
|
|
92
97
|
<div class="ob-done-icon"><span data-icon="mail"></span></div>
|
|
93
98
|
<h2 class="login-heading">Check your email</h2>
|
|
94
99
|
<p class="text-muted mb-4">If an account exists for that address, we've sent a reset link. It expires in
|
|
95
|
-
1 hour.</p>
|
|
100
|
+
<span id="forgot-success-expiry">1 hour</span>. Nothing there? Check your spam folder.</p>
|
|
96
101
|
<a href="#" id="forgot-success-back-link" class="btn btn-primary btn-block">Back to sign in</a>
|
|
97
102
|
</div>
|
|
98
103
|
|
|
99
|
-
<!-- ── Reset password ── -->
|
|
104
|
+
<!-- ── Reset password (the link was checked on opening) ── -->
|
|
100
105
|
<div id="reset-panel" style="display:none">
|
|
101
106
|
<h2 class="login-heading">Set a new password</h2>
|
|
102
|
-
<p class="text-muted mb-
|
|
107
|
+
<p class="text-muted mb-3">For <strong id="reset-email"></strong>. This link works once and
|
|
108
|
+
expires in <span id="reset-expiry"></span>.</p>
|
|
103
109
|
<div id="reset-form-container"></div>
|
|
110
|
+
<ul id="reset-rules" class="login-rules" aria-live="polite" style="display:none"></ul>
|
|
111
|
+
<p class="text-muted login-small">Setting it signs this account out everywhere else, and we email you
|
|
112
|
+
to say it changed.</p>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- ── The reset link is no good ── -->
|
|
116
|
+
<div id="reset-invalid-panel" style="display:none">
|
|
117
|
+
<div class="ob-done-icon login-icon-warn"><span data-icon="clock"></span></div>
|
|
118
|
+
<h2 class="login-heading">This link has expired or was already used</h2>
|
|
119
|
+
<p class="text-muted mb-4">Reset links work once and only for a limited time. A newer link also
|
|
120
|
+
replaces an older one - use the most recent email. You can ask for a new link now.</p>
|
|
121
|
+
<a href="#" id="reset-invalid-new" class="btn btn-primary btn-block">Send me a new link</a>
|
|
122
|
+
<a href="#" id="reset-invalid-back" class="btn-skip">Back to sign in</a>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<!-- ── Reset done ── -->
|
|
126
|
+
<div id="reset-done-panel" style="display:none">
|
|
127
|
+
<div class="ob-done-icon"><span data-icon="check-circle"></span></div>
|
|
128
|
+
<h2 class="login-heading">Password changed</h2>
|
|
129
|
+
<p class="text-muted mb-4">Sign in with your new password. Anywhere else you were signed in has been
|
|
130
|
+
signed out.</p>
|
|
131
|
+
<a href="#" id="reset-done-signin" class="btn btn-primary btn-block">Sign in</a>
|
|
104
132
|
</div>
|
|
105
133
|
</div>
|
|
106
134
|
</div>
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
|
|
20
20
|
<!-- Installed -->
|
|
21
21
|
<div class="tab-panel active">
|
|
22
|
+
<div class="plx-card" data-bind-hidden="!tools.value.length">
|
|
23
|
+
<div class="plx-bar">
|
|
24
|
+
<strong>Built-in Tools</strong>
|
|
25
|
+
<span data-help-title="Built-in Tools" data-help="Part of the CMS and on unless you switch them off. Switching one off hides it and stops its screens - its data is kept, and switching it back on brings everything back. A plugin that needs one goes off with it, and you are told first. A Tool marked Locked is held by your site's manager."></span>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="plx-list plx-tools" data-each="toolRows.value key=name"></div>
|
|
28
|
+
</div>
|
|
22
29
|
<div class="plx-card">
|
|
23
30
|
<div class="plx-bar">
|
|
24
31
|
<div class="plx-seg" role="group" aria-label="Show">
|
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
<label class="form-label" for="stx-tagline">Tagline</label>
|
|
46
46
|
<input id="stx-tagline" type="text" class="form-input" placeholder="Built with Domma CMS" data-model="tagline.value">
|
|
47
47
|
</div>
|
|
48
|
+
<div>
|
|
49
|
+
<label class="form-label" for="stx-site-url">Site URL
|
|
50
|
+
<span data-help-title="Site URL" data-help="The site's own address, such as https://example.com. Password reset links are always built on it, and so are canonical links and the sitemap. Leave it empty and the site uses the address people sign in to the admin at - set it if the site has more than one address, or before anyone has signed in since an update."></span></label>
|
|
51
|
+
<input id="stx-site-url" type="url" class="form-input" placeholder="https://example.com" autocomplete="off" data-model="siteUrl.value">
|
|
52
|
+
<small class="text-danger" data-bind-hidden="!siteUrlError.value" data-bind-text="siteUrlError.value"></small>
|
|
53
|
+
</div>
|
|
48
54
|
</div>
|
|
49
55
|
</div>
|
|
50
56
|
<div class="stx-card">
|
|
@@ -56,6 +56,13 @@
|
|
|
56
56
|
data-bind-placeholder="pwPlaceholder.value" data-model="password.value">
|
|
57
57
|
<small class="uex-err" data-bind-hidden="!err.value.password" data-bind-text="err.value.password"></small>
|
|
58
58
|
<label class="uex-show"><input type="checkbox" data-model="showPw.value"> Show what I type</label>
|
|
59
|
+
<div class="uex-reset" data-bind-hidden="!canReset.value">
|
|
60
|
+
<small>Or let them choose it:</small>
|
|
61
|
+
<button type="button" class="btn btn-xs btn-ghost" data-on-click="resetEmail"><span data-icon="mail" data-icon-size="13"></span> Email a reset link</button>
|
|
62
|
+
<button type="button" class="btn btn-xs btn-ghost" data-on-click="resetLink"><span data-icon="link" data-icon-size="13"></span> Copy a reset link</button>
|
|
63
|
+
<span data-help-title="Reset links" data-help="Rather than typing a password for them, send a link they use to choose their own. It works once and expires (config/auth.json resetTokenExpiry, an hour by default); their current password keeps working until they use it. Copy a reset link when the site has no mail server or their mail is not arriving - send it to them privately. A newer link replaces an older one; Users › ⋮ can withdraw one."></span>
|
|
64
|
+
<small class="uex-reset-open" data-bind-hidden="!resetOpen.value">A reset link is open and has not been used yet.</small>
|
|
65
|
+
</div>
|
|
59
66
|
</div>
|
|
60
67
|
<div class="uex-setting">
|
|
61
68
|
<label class="form-label" for="uex-active">Active account
|
package/admin/js/views/login.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as l,isAuthenticated as N,setAuthData as b,takePendingLogout as U}from"../api.js";import{themesInDisplayOrder as V}from"../lib/themes.js";import{enhancePasswordFields as v}from"../lib/password-field.js";import{minutesText as W,passwordVerdict as Y,readLoginHash as j,resetTokenFrom as G}from"../lib/login-arrange.js";const H={name:{type:"string",required:!0,minLength:2,label:"Full Name",formConfig:{placeholder:"Your name",autocomplete:"name"}},email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"admin@example.com",autocomplete:"email"}},password:{type:"password",required:!0,minLength:8,label:"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Minimum 8 characters"}}},K={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}},password:{type:"password",required:!0,label:"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"current-password"}}};export const loginView={templateUrl:"/admin/js/templates/login.html",async onMount(e){Q();const t=U(),s=window.location.hash,{next:o,expired:r}=j(s);if(N()){k(s.startsWith("#/reset-password")?"/":o);return}if(s.startsWith("#/reset-password")){await ee(e,G(s));return}let c=!1;const p=E.loader(e.get(0),{type:"dots"});try{const u=await l.auth.setupStatus();c=u.needsSetup,x(e,u)}catch{E.toast("Could not reach the server.",{type:"error"})}finally{p.destroy()}c?(a(e,"setup"),Z(e)):(a(e,"login"),A(e,{next:o}),r&&!t&&e.find("#login-notice").text("Your session ended - sign in again to carry on where you were.").show()),Domma.icons.scan()}};const z={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}}},J={password:{type:"password",required:!0,minLength:8,label:"New Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password"}},confirmPassword:{type:"password",required:!0,label:"Confirm Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password"}}};let P=Promise.resolve();function Q(){let e;P=new Promise(o=>{e=o});const t=M.subscribe("router:afterChange",()=>{s(),e()}),s=()=>{typeof t=="function"&&t()};setTimeout(()=>{s(),e()},1500)}function k(e){P.then(()=>R.navigate(e))}let g={byEmail:!0,expiresIn:""};function x(e,t={}){g={byEmail:t.resetByEmail!==!1,expiresIn:t.resetExpiresIn||""},t.siteTitle&&e.find("#login-site-title").text(t.siteTitle)}const X=["setup","onboarding-site","onboarding-theme","onboarding-done","login","forgot","forgot-success","reset","reset-invalid","reset-done","challenge"];function a(e,t){X.forEach(s=>e.find(`#${s}-panel`).hide()),e.find(`#${t}-panel`).show(),Domma.icons.scan()}function D(e,t,s){e.find(`#${t}`).text(s).show()}function Z(e){F.render("#setup-form-container",H,{},{layout:"stacked",submitText:"Create admin account",onSubmit:async t=>{try{const s=await l.auth.setup(t);b(s),a(e,"onboarding-site"),_(e)}catch(s){return E.toast(s.message||"Setup failed. Please try again.",{type:"error"}),!1}}}),v(e.find("#setup-form-container").get(0))}function _(e){e.find("#ob-site-skip").on("click",t=>{t.preventDefault(),a(e,"onboarding-theme"),I(e),L(e)}),e.find("#ob-site-btn").on("click",async()=>{e.find("#ob-site-error").hide();const t=e.find("#ob-title").val().trim(),s=e.find("#ob-tagline").val().trim(),o=e.find("#ob-site-btn").prop("disabled",!0).text("Saving\u2026");try{const r=await l.settings.get();await l.settings.save({...r,title:t||r.title,tagline:s||r.tagline,seo:{...r.seo||{},defaultTitle:t||r.seo&&r.seo.defaultTitle}});const c=await l.navigation.get();await l.navigation.save({...c,brand:{...c.brand||{},text:t||c.brand.text}}),a(e,"onboarding-theme"),I(e),L(e)}catch(r){D(e,"ob-site-error",r.message||"Could not save site details.")}finally{o.prop("disabled",!1).text("Continue")}})}function I(e){const t=e.find("#theme-grid").empty();V().forEach(s=>{const o=$(`
|
|
2
2
|
<div class="theme-swatch" data-theme="${s.id}" title="${s.id}">
|
|
3
3
|
<div class="theme-swatch-preview" style="background:${s.bg}">
|
|
4
4
|
<div class="theme-swatch-accent" style="background:${s.primary}"></div>
|
|
@@ -8,4 +8,4 @@ import{api as r,isAuthenticated as k,setAuthData as p,takePendingLogout as x}fro
|
|
|
8
8
|
<span class="theme-swatch-mode">${s.dark?"Dark":"Light"}</span>
|
|
9
9
|
</div>
|
|
10
10
|
</div>
|
|
11
|
-
`);o.on("click",()=>{e.find(".theme-swatch").removeClass("selected"),o.addClass("selected")}),t.append(o)}),e.find('[data-theme="charcoal-dark"]').addClass("selected")}function
|
|
11
|
+
`);o.on("click",()=>{e.find(".theme-swatch").removeClass("selected"),o.addClass("selected")}),t.append(o)}),e.find('[data-theme="charcoal-dark"]').addClass("selected")}function L(e){e.find("#ob-theme-skip").on("click",t=>{t.preventDefault(),a(e,"onboarding-done")}),e.find("#ob-theme-btn").on("click",async()=>{e.find("#ob-theme-error").hide();const t=e.find(".theme-swatch.selected").attr("data-theme")||"charcoal-dark",s=e.find("#ob-theme-btn").prop("disabled",!0).text("Applying\u2026");try{const o=await l.settings.get();await l.settings.save({...o,theme:t}),a(e,"onboarding-done")}catch(o){D(e,"ob-theme-error",o.message||"Could not save theme.")}finally{s.prop("disabled",!1).text("Apply theme")}})}function A(e,{next:t="/",email:s=""}={}){F.render("#login-form-container",K,s?{email:s}:{},{layout:"stacked",submitText:"Sign in",onSubmit:async o=>{try{const r=await l.auth.login(o);if(r?.challenge&&r.ticket)return q(e,r,t),!1;b(r),k(t)}catch(r){return E.toast(r.message||"Invalid credentials. Please try again.",{type:"error"}),!1}}}),v(e.find("#login-form-container").get(0)),e.find("#forgot-link").off("click").on("click",o=>{o.preventDefault();const r=String(e.find('#login-form-container input[type="email"]').val()||"").trim();a(e,"forgot"),B(e,r)})}function q(e,{challenge:t,ticket:s},o="/"){let r="code";const c=e.find("#challenge-code"),p=e.find("#challenge-error"),u=e.find("#challenge-recovery"),m=f=>{r=f;const i=f==="recovery";e.find("#challenge-label").text(i?"Recovery code":t.label||"Code"),e.find("#challenge-help").text(i?"Type one of the recovery codes you saved when you set this up. Each works once.":t.help||"Enter the code to finish signing in."),c.val("").attr("inputmode",i||t.inputMode==="text"?"text":"numeric"),u.text(i?"Use the code instead":"Use a recovery code instead"),p.hide(),setTimeout(()=>c.trigger("focus"),0)};e.find("#challenge-title").text(t.title||"One more step");const y=!!(t.image||t.secret);e.find("#challenge-enrol").css("display",y?"":"none");const h=e.find("#challenge-image").get(0);h&&(h.src=t.image||"",h.style.display=t.image?"":"none"),e.find("#challenge-secret").text(t.secret||""),e.find("#challenge-copy").off("click").on("click",async()=>{try{await navigator.clipboard.writeText(String(t.secret||"").replace(/\s+/g,"")),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - select the key and copy it.",{type:"warning"})}}),e.find("#challenge-trust-wrap").css("display",t.trust===!0?"":"none"),u.css("display",t.recovery===!0?"":"none"),m("code"),a(e,"challenge"),u.off("click").on("click",f=>{f.preventDefault(),m(r==="code"?"recovery":"code")}),e.find("#challenge-back").off("click").on("click",f=>{f.preventDefault(),a(e,"login")}),e.find("#challenge-form").off("submit").on("submit",async f=>{f.preventDefault();const i=e.find("#challenge-submit").prop("disabled",!0);try{const d=await l.auth.verifyLogin({ticket:s,response:String(c.val()||"").trim(),mode:r,trust:e.find("#challenge-trust").prop("checked")===!0});if(d?.challenge&&d.ticket){q(e,d,o);return}b(d),k(o)}catch(d){const n=d.message||"That did not match.";p.text(n).show(),/sign in again/i.test(n)&&setTimeout(()=>a(e,"login"),1800)}finally{i.prop("disabled",!1)}})}function B(e,t=""){const s=o=>{o.preventDefault(),a(e,"login")};if(e.find("#forgot-back-link").off("click").on("click",s),e.find("#forgot-success-back-link").off("click").on("click",s),!g.byEmail){e.find("#forgot-intro").hide(),e.find("#forgot-form-container").empty(),e.find("#forgot-unavailable").show();return}e.find("#forgot-intro").show(),e.find("#forgot-unavailable").hide(),F.render("#forgot-form-container",z,t?{email:t}:{},{layout:"stacked",submitText:"Send reset link",onSubmit:async o=>{try{const r=await l.auth.forgotPassword(o.email);r?.expiresIn&&(g.expiresIn=r.expiresIn)}catch{}g.expiresIn&&e.find("#forgot-success-expiry").text(g.expiresIn),a(e,"forgot-success"),Domma.icons.scan()}})}async function ee(e,t){if(!t){w(e);return}const s=E.loader(e.get(0),{type:"dots"});let o;l.auth.setupStatus().then(r=>x(e,r)).catch(()=>{});try{o=await l.auth.checkReset(t)}catch{w(e);return}finally{s.destroy()}e.find("#reset-email").text(o.email||""),e.find("#reset-expiry").text(W(o.minutesLeft)),a(e,"reset"),te(e,t,o.email||"")}function w(e){a(e,"reset-invalid"),e.find("#reset-invalid-back").off("click").on("click",t=>{t.preventDefault(),window.location.hash="#/login"}),e.find("#reset-invalid-new").off("click").on("click",async t=>{t.preventDefault();try{x(e,await l.auth.setupStatus())}catch{}a(e,"forgot"),B(e)})}function te(e,t,s){let o=[],r=0,c=null;const p=e.find("#reset-rules"),u=()=>({password:String(e.find('#reset-form-container input[name="password"]').val()||""),confirm:String(e.find('#reset-form-container input[name="confirmPassword"]').val()||"")}),m=()=>{const{password:i,confirm:d}=u(),n=Y({password:i,confirm:d,problems:o}),S=[...n.messages,...n.mismatch?["The two passwords do not match yet."]:[]],T=p.get(0);T.textContent="";for(const O of S){const C=document.createElement("li");C.textContent=O,T.appendChild(C)}return p.css("display",S.length&&i?"":"none"),n},y=async()=>{const{password:i}=u(),d=++r;if(i.length<8){o=[],m();return}try{const n=await l.auth.checkReset(t,i);d===r&&(o=n.problems||[])}catch(n){if(n.body?.expired){w(e);return}}d===r&&m()};F.render("#reset-form-container",J,{},{layout:"stacked",submitText:"Set new password",onSubmit:async i=>{await y();const d=m();if(!d.ok)return E.toast(d.mismatch?"The passwords do not match.":"That password does not meet the rules below.",{type:"error"}),!1;try{await l.auth.resetPassword(t,i.password),history.replaceState(null,"","#/reset-password"),a(e,"reset-done"),e.find("#reset-done-signin").off("click").on("click",n=>{n.preventDefault(),a(e,"login"),A(e,{email:s})})}catch(n){return n.body?.expired?(w(e),!1):(o=n.body?.problems||[],m(),E.toast(n.message||"The password was not changed.",{type:"error"}),!1)}}});const h=e.find("#reset-form-container form").get(0)||e.find("#reset-form-container").get(0);if(h&&s){const i=document.createElement("input");i.type="email",i.name="username",i.autocomplete="username",i.value=s,i.readOnly=!0,i.hidden=!0,h.prepend(i)}v(e.find("#reset-form-container").get(0));const f=i=>{i.target.matches?.('input[type="password"], input[name="password"], input[name="confirmPassword"]')&&(i.target.name==="password"&&(o=[]),m(),clearTimeout(c),i.target.name==="password"&&(c=setTimeout(y,400)))};e.find("#reset-form-container").get(0)?.addEventListener("input",f)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{onceClosed as Xe,openMenuAt as Ae,sidebarCount as Ze,viewLifetime as et}from"../lib/tool-kit.js";import{pluginAccent as Le}from"../lib/plugin-accent.js";import{reportRestart as tt}from"../lib/server-restart.js";import{FILTERS as at,arrangeCatalogue as nt,arrangePlugins as st,catalogueAction as ce,catalogueRow as lt,checkScaffold as it,fmtReleased as ot,needsAttention as rt,planLines as ct,pluginRow as dt,pluginStatus as Ue,readSettings as ut,settingsFields as Re,slugifyPlugin as pt,updateFor as je}from"../lib/plugins-arrange.js";const Be=/^#\/plugins\/?(\?.*)?$/,mt="#/plugins",de="plugins.filter",ee="plugins.draft",bt=`
|
|
2
2
|
<div class="plx-row" data-name="{{name}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
|
|
3
3
|
<span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
|
|
4
4
|
<span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
|
|
@@ -6,10 +6,20 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
|
|
|
6
6
|
<span class="plx-chip is-warn" data-bind-hidden="!update" data-bind-text="updateText"></span>
|
|
7
7
|
<span class="plx-chip" data-bind-hidden="!entText" data-bind-class="entCls" data-bind-text="entText" title="{{entTitle}}"></span>
|
|
8
8
|
<span class="plx-chip is-info" data-bind-hidden="!licensed" title="Licensed - its source is not viewable here">Licensed</span>
|
|
9
|
+
<span class="plx-chip is-info" data-bind-hidden="!locked" title="Your site's manager holds this switch">Locked</span>
|
|
9
10
|
<span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
|
|
10
11
|
</span>
|
|
11
12
|
<button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
|
|
12
|
-
</div>`,
|
|
13
|
+
</div>`,vt=`
|
|
14
|
+
<div class="plx-row" data-tool="{{name}}" tabindex="0" data-bind-class="rowCls">
|
|
15
|
+
<span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
|
|
16
|
+
<span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
|
|
17
|
+
<span class="plx-chips">
|
|
18
|
+
<span class="plx-chip is-info" data-bind-hidden="!locked" title="Your site's manager holds this switch">Locked</span>
|
|
19
|
+
<span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
|
|
20
|
+
</span>
|
|
21
|
+
<button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
|
|
22
|
+
</div>`,ft=`
|
|
13
23
|
<div class="plx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
|
|
14
24
|
<span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
|
|
15
25
|
<span class="plx-main"><strong>{{displayName}}</strong><small>{{meta}}</small></span>
|
|
@@ -19,7 +29,7 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
|
|
|
19
29
|
</span>
|
|
20
30
|
<button type="button" class="btn btn-sm plx-act" data-bind-class="actCls" data-bind-disabled="actDisabled" data-bind-text="actionText"></button>
|
|
21
31
|
<button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
|
|
22
|
-
</div>`,r=O=>String(O??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function ce(O){const p=document.createElement("template");return p.innerHTML=O.trim(),p.content.firstElementChild}const h=Je(Le);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(O){h.reset();const p=O.get(0).querySelector(".plx")||O.get(0),k=()=>Le.test(location.hash)&&p.isConnected,L=M.observable([]),b=M.observable({available:null,site:null,items:[]}),de=M.observable(!0),Z=M.observable(""),m=M.observable("");let ue="all";try{ue=Ge.includes(S.get(re))?S.get(re):"all"}catch{}const C=M.observable(ue),K=M.observable(""),R=M.observable("all"),pe=M.observable("");h.add(M.effect(()=>{const e=C.value;try{S.set(re,e)}catch{}}));const V=M.computed(()=>new Map((b.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(b.peek().items||[]).find(t=>t.slug===e),ee=M.observable([]);h.add(M.effect(()=>{const e=V.value;ee.value=Ze(L.value,{filter:C.value,query:K.value,latest:e}).map(t=>({...st(t,e),accent:Ne(t.name)}))}));const Re=M.computed(()=>{const e=L.value,t=V.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>nt(n,t)).length}}),_=M.computed(()=>(b.value.items||[]).filter(e=>oe(e)==="update").length),je=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),te=M.observable([]);h.add(M.effect(()=>{te.value=Xe(b.value.items,{filter:R.value,query:pe.value}).map(e=>{const t=et(e),a=t.action==="update"||t.action==="install";return{...t,accent:Ne(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const Fe=M.computed(()=>{const e=b.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>!t.installed&&t.entitled).length,updates:_.value}}),H=e=>L.peek().find(t=>t.name===e);async function ae(){try{const e=await U.plugins.list();if(!k())return;L.value=Array.isArray(e)?e:[],Z.value=""}catch(e){Z.value=`Could not load the plugins: ${e.message||e}`}finally{de.value=!1}}async function be(){try{const e=await U.marketplace.catalogue();if(!k())return;b.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{b.value={available:!1,site:null,items:[],failed:!0}}}const B=async()=>{await Promise.all([ae(),be()]),Ye(ot)};async function ne(e,t){if(e.core)return;const a=t?"on":"off";if(await E.confirm(`Switch ${r(e.displayName||e.name)} ${a}? The server restarts to apply it, which takes a few seconds.`)&&!m.peek()){m.value=`Switching ${e.displayName||e.name} ${a}\u2026`;try{const n=await U.plugins.update(e.name,{enabled:t});await Qe(n,{onStatus:l=>{m.value=l}}),E.toast(`${e.displayName||e.name} is ${a}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(n){E.toast(`Not switched ${a}: ${n.message||n}`,{type:"error"})}finally{m.value="",k()&&await B()}}}async function j(e,{update:t=!1}={}){const a=r(e.displayName||e.slug),n=t?`<p>Update ${a} from ${r(e.installedVersion)} to ${r(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${r(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await U.marketplace.install(e.slug,e.version,{update:t}),v=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${v} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await B()}}async function me(e,t){if(await E.confirm(`<p>Uninstall ${r(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{await U.marketplace.uninstall(e),E.toast(`${t||e} uninstalled. Restart the server to apply.`,{type:"success",duration:6e3})}catch(a){E.toast(`Uninstall failed: ${a.message||a}`,{type:"error"})}k()&&await B()}}async function ve(e){try{await U.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const fe=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},q=p.querySelector(".plx-file");async function De(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await U.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${r(n.name)} ${r(n.installedVersion||"the installed version")} is already installed. Update it to ${r(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${r(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${r(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",k()&&await B()}}const ge=async()=>{const e=q.files?.[0];q.value="",e&&await De(e)};q.addEventListener("change",ge),h.add(()=>q.removeEventListener("change",ge));const Y=new Set;let A=null,he=!1;function ye({title:e,size:t="md",panel:a,model:n,methods:l,dirty:v=()=>!1,form:u=null,cleanup:c=()=>{}}){const i=E.slideover({title:r(e),size:t,position:"right"});i.element.appendChild(a);const $=Te(a),x=M.applyBindings(n,a,{methods:l});u&&(A=u),Y.add(i),He(i,()=>{c(),x.dispose(),$(),Y.delete(i),A===u&&(A=null)});const w=i.close.bind(i);let f=!1,T=!1;return i.close=async F=>{if(!f&&F!==!0&&v()){if(T)return;T=!0;const D=await E.confirm("Discard the changes you have not saved?");if(T=!1,!D)return}return f=!0,w()},I.scan(a),i.open(),i}function se(e,t=null){const a=H(e);if(!a)return;const n=Ue(a),l=Oe(a),v=W(a.name),u=Ae(a,V.peek()),c=!!(u&&v?.entitled),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(d=>d.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>lt(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),T=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${u?` (v${u} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",at(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),D=(s,d)=>{const N=`plx-set-${d}`,Q=s.help?` <span data-help="${r(s.help)}"></span>`:"",z=`<label class="form-label" for="${N}">${r(s.label)}${Q}</label>`,P=`data-model="v${d}.value"`,G=`<small class="plx-err" data-bind-hidden="!err${d}.value" data-bind-text="err${d}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${z}<input id="${N}" type="checkbox" class="form-switch" ${P}></div>`;if(s.type==="select")return`<div>${z}<select id="${N}" class="form-input" ${P}>${s.options.map(Me=>`<option value="${r(Me.value)}">${r(Me.label)}</option>`).join("")}</select>${G}</div>`;if(s.type==="json")return`<div>${z}<textarea id="${N}" class="form-input plx-json" rows="5" spellcheck="false" ${P}></textarea>${G}</div>`;if(s.type==="textarea")return`<div>${z}<textarea id="${N}" class="form-input" rows="3" ${P}></textarea>${G}</div>`;const Ve=s.type==="number"?"number":s.secret?"password":"text",We=`${s.min!=null?` min="${r(s.min)}"`:""}${s.max!=null?` max="${r(s.max)}"`:""}`;return`<div>${z}<input id="${N}" type="${Ve}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${We} ${P}>${G}</div>`},o={saving:f,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,d)=>[[`v${d}`,x[s.key]],[`err${d}`,M.computed(()=>w.value.errors[s.key]||"")]]))},g=ce(`
|
|
32
|
+
</div>`,c=A=>String(A??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function ue(A){const b=document.createElement("template");return b.innerHTML=A.trim(),b.content.firstElementChild}const g=et(Be);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(A){g.reset();const b=A.get(0).querySelector(".plx")||A.get(0),$=()=>Be.test(location.hash)&&b.isConnected,U=M.observable([]),q=M.observable([]),pe=M.computed(()=>q.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),v=M.observable({available:null,site:null,items:[]}),me=M.observable(!0),te=M.observable(""),m=M.observable("");let be="all";try{be=at.includes(S.get(de))?S.get(de):"all"}catch{}const T=M.observable(be),W=M.observable(""),R=M.observable("all"),ve=M.observable("");g.add(M.effect(()=>{const e=T.value;try{S.set(de,e)}catch{}}));const _=M.computed(()=>new Map((v.value.items||[]).map(e=>[e.slug,e.version]))),H=e=>(v.peek().items||[]).find(t=>t.slug===e),ae=M.observable([]);g.add(M.effect(()=>{const e=_.value;ae.value=st(U.value,{filter:T.value,query:W.value,latest:e}).map(t=>({...dt(t,e),accent:Le(t.name)}))}));const Fe=M.computed(()=>{const e=U.value,t=_.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>rt(n,t)).length}}),Y=M.computed(()=>(v.value.items||[]).filter(e=>ce(e)==="update").length),De=M.computed(()=>U.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),ne=M.observable([]);g.add(M.effect(()=>{ne.value=nt(v.value.items,{filter:R.value,query:ve.value}).map(e=>{const t=lt(e),a=t.action==="update"||t.action==="install";return{...t,accent:Le(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const qe=M.computed(()=>{const e=v.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>!t.installed&&t.entitled).length,updates:Y.value}}),J=e=>U.peek().find(t=>t.name===e);async function se(){try{const[e,t]=await Promise.all([k.plugins.list(),k.tools.list().catch(()=>[])]);if(!$())return;U.value=Array.isArray(e)?e:[],q.value=Array.isArray(t)?t:[],te.value=""}catch(e){te.value=`Could not load the plugins: ${e.message||e}`}finally{me.value=!1}}async function fe(){try{const e=await k.marketplace.catalogue();if(!$())return;v.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{v.value={available:!1,site:null,items:[],failed:!0}}}const z=async()=>{await Promise.all([se(),fe()]),Ze(mt)};async function j(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${c(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||m.peek())return;const l=o=>a==="tool"?k.tools.update(e.name,{enabled:t,cascade:o}):k.plugins.update(e.name,{enabled:t,cascade:o});m.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let o;try{o=await l(!1)}catch(d){if(d.status!==409||!d.body?.needsConfirm)throw d;const u=ct(d.body.plan);if(m.value="",!await E.confirm(`<p>Switching ${c(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(i=>`<li>${c(i)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;m.value=`Switching ${e.displayName||e.name} ${n}\u2026`,o=await l(!0)}await tt(o,{onStatus:d=>{m.value=d}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(o){E.toast(`Not switched ${n}: ${o.message||o}`,{type:"error"})}finally{m.value="",$()&&await z()}}async function B(e,{update:t=!1}={}){const a=c(e.displayName||e.slug),n=t?`<p>Update ${a} from ${c(e.installedVersion)} to ${c(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${c(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await k.marketplace.install(e.slug,e.version,{update:t}),o=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${o} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}$()&&await z()}}async function he(e,t){if(await E.confirm(`<p>Uninstall ${c(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{await k.marketplace.uninstall(e),E.toast(`${t||e} uninstalled. Restart the server to apply.`,{type:"success",duration:6e3})}catch(a){E.toast(`Uninstall failed: ${a.message||a}`,{type:"error"})}$()&&await z()}}async function ge(e){try{await k.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ye=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},P=b.querySelector(".plx-file");async function ze(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await k.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${c(n.name)} ${c(n.installedVersion||"the installed version")} is already installed. Update it to ${c(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${c(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${c(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",$()&&await z()}}const xe=async()=>{const e=P.files?.[0];P.value="",e&&await ze(e)};P.addEventListener("change",xe),g.add(()=>P.removeEventListener("change",xe));const Q=new Set;let L=null,we=!1;function ke({title:e,size:t="md",panel:a,model:n,methods:l,dirty:o=()=>!1,form:d=null,cleanup:u=()=>{}}){const i=E.slideover({title:c(e),size:t,position:"right"});i.element.appendChild(a);const C=Oe(a),x=M.applyBindings(n,a,{methods:l});d&&(L=d),Q.add(i),Xe(i,()=>{u(),x.dispose(),C(),Q.delete(i),L===d&&(L=null)});const w=i.close.bind(i);let f=!1,N=!1;return i.close=async F=>{if(!f&&F!==!0&&o()){if(N)return;N=!0;const D=await E.confirm("Discard the changes you have not saved?");if(N=!1,!D)return}return f=!0,w()},I.scan(a),i.open(),i}function le(e,t=null){const a=J(e);if(!a)return;const n=Ue(a),l=Re(a),o=H(a.name),d=je(a,_.peek()),u=!!(d&&o?.entitled),C={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable(C[s.key])])),w=M.computed(()=>ut(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),N=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${d?` (v${d} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",ot(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),D=(s,p)=>{const O=`plx-set-${p}`,X=s.help?` <span data-help="${c(s.help)}"></span>`:"",K=`<label class="form-label" for="${O}">${c(s.label)}${X}</label>`,V=`data-model="v${p}.value"`,Z=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${K}<input id="${O}" type="checkbox" class="form-switch" ${V}></div>`;if(s.type==="select")return`<div>${K}<select id="${O}" class="form-input" ${V}>${s.options.map(Ne=>`<option value="${c(Ne.value)}">${c(Ne.label)}</option>`).join("")}</select>${Z}</div>`;if(s.type==="json")return`<div>${K}<textarea id="${O}" class="form-input plx-json" rows="5" spellcheck="false" ${V}></textarea>${Z}</div>`;if(s.type==="textarea")return`<div>${K}<textarea id="${O}" class="form-input" rows="3" ${V}></textarea>${Z}</div>`;const Je=s.type==="number"?"number":s.secret?"password":"text",Qe=`${s.min!=null?` min="${c(s.min)}"`:""}${s.max!=null?` max="${c(s.max)}"`:""}`;return`<div>${K}<input id="${O}" type="${Je}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Qe} ${V}>${Z}</div>`},r={saving:f,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,x[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},h=ue(`
|
|
23
33
|
<div class="plx-form">
|
|
24
34
|
<p class="plx-desc"></p>
|
|
25
35
|
<dl class="plx-facts"></dl>
|
|
@@ -31,7 +41,7 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
|
|
|
31
41
|
<p class="plx-hint" data-if="!hasFields">This plugin has no settings to change here.</p>
|
|
32
42
|
<div class="plx-actions">
|
|
33
43
|
${a.core?"":`<button type="button" class="btn btn-sm btn-ghost" data-on-click="toggle"><span data-icon="${a.enabled?"pause":"play"}" data-icon-size="13"></span> ${a.enabled?"Switch off":"Switch on"}</button>`}
|
|
34
|
-
${
|
|
44
|
+
${u?`<button type="button" class="btn btn-sm btn-ghost" data-on-click="update"><span data-icon="download" data-icon-size="13"></span> Update to v${c(d)}</button>`:""}
|
|
35
45
|
${a.sourceEditable!==!1?'<button type="button" class="btn btn-sm btn-ghost" data-on-click="source"><span data-icon="code" data-icon-size="13"></span> Source</button>':""}
|
|
36
46
|
${a.closedSource?"":'<button type="button" class="btn btn-sm btn-ghost" data-on-click="exportIt" title="Download as a .dcmsplugin file"><span data-icon="download" data-icon-size="13"></span> Export</button>'}
|
|
37
47
|
<span class="plx-spacer"></span>
|
|
@@ -39,7 +49,7 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
|
|
|
39
49
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
|
|
40
50
|
<span data-icon="check" data-icon-size="13"></span> Save settings</button>
|
|
41
51
|
</div>
|
|
42
|
-
</div>`);
|
|
52
|
+
</div>`);h.querySelector(".plx-desc").textContent=a.description||"";const Ye=h.querySelector(".plx-facts");for(const[s,p]of F){const O=document.createElement("dt");O.textContent=s;const X=document.createElement("dd");X.textContent=p,Ye.append(O,X)}async function Te(){if(r.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await k.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),G.close(!0),$()&&await se()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const re=s=>async()=>{await G.close(),Q.has(G)||s()},G=ke({title:a.displayName||a.name,size:"md",panel:h,model:r,dirty:N,form:l.length?{kind:"settings",name:a.name,dirty:N,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=x[p.key].peek());return s}}:null,methods:{save:Te,cancel:()=>G.close(),toggle:re(()=>j(a,!a.enabled)),update:re(()=>B(o,{update:!0})),source:re(()=>ye(a)),exportIt:()=>ge(a)}});h.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Te())})}function $e(e=null){const t=Ge(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([r,h])=>[r,h.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([r,h])=>[r,h.peek()])),o=()=>JSON.stringify(l())!==n||!!e;let d=!!e?.slug;const u=M.effect(()=>{const r=a.displayName.value;d||(a.slug.value=pt(r))}),i=M.observable(!1),C=M.computed(()=>it(Object.fromEntries(Object.entries(a).map(([r,h])=>[r,h.value])),U.value.map(r=>r.name))),x=r=>M.computed(()=>C.value.errors[r]||""),w={...a,saving:i,nameErr:x("displayName"),slugErr:x("slug"),descErr:x("description"),authorErr:x("author"),iconErr:x("icon"),cannot:M.computed(()=>i.value||Object.keys(C.value.errors).length>0)},f=ue(`
|
|
43
53
|
<div class="plx-form">
|
|
44
54
|
<div>
|
|
45
55
|
<label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
|
|
@@ -75,7 +85,7 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
|
|
|
75
85
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
|
|
76
86
|
<span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
|
|
77
87
|
</div>
|
|
78
|
-
</div>`);async function
|
|
88
|
+
</div>`);async function N(){if(w.cannot.peek())return;i.value=!0;const{payload:r}=C.peek();try{await k.plugins.scaffold(r),E.toast(`"${r.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),$()&&await z()}catch(h){E.toast(h.message||"Could not create the plugin.",{type:"error"})}finally{i.value=!1}}const F=ke({title:"New plugin",size:"sm",panel:f,model:w,dirty:o,form:{kind:"new",name:null,dirty:o,values:l},cleanup:()=>u(),methods:{create:N,cancel:()=>F.close()}}),D=f.querySelector("#plx-np-slug");D.addEventListener("input",()=>{d=!!D.value}),f.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),N())}),f.querySelector("#plx-np-name")?.focus()}function Pe(e){const t=J(e);if(!t)return[];const a=H(t.name),n=je(t,_.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Re(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>le(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>j(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>j(t,!0)}],...n?[{label:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled,action:()=>B(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>ye(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ge(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>he(t.name,t.displayName)}]]}function Ke(e){const t=q.peek().find(n=>n.name===e);if(!t)return[];const a=!!m.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>j(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>j(t,!0,"tool")}]}const Ve=e=>{const t=q.peek().find(a=>a.name===e);t&&j(t,!t.enabled,"tool")};function We(e){const t=H(e);if(!t)return[];const a=ce(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{W.value=t.slug,T.value="all",Me.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>he(t.slug,t.displayName)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[]]}function _e(e){const t=H(e);if(!t)return;const a=ce(t);a==="install"?B(t):a==="update"?B(t,{update:!0}):t.installed&&(W.value=t.slug,T.value="all",Me.activate?.(0))}function ie(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});g.add(()=>l?.destroy?.());const o=u=>{const i=u.target.closest(".plx-row");if(!i)return;const C=u.target.closest(".plx-kebab");if(C){Ae(C);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(i.dataset[t]);return}n(i.dataset[t])},d=u=>{const i=u.target.closest(".plx-row");i&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ae(i)):u.key==="Enter"&&u.target===i&&n(i.dataset[t]))};e.addEventListener("click",o),e.addEventListener("keydown",d),g.add(()=>{e.removeEventListener("click",o),e.removeEventListener("keydown",d)})}const oe=b.querySelector(".plx-browse");oe.appendChild(ue(`
|
|
79
89
|
<div class="plx-card">
|
|
80
90
|
<p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
|
|
81
91
|
<p class="plx-note" data-bind-hidden="browseState.value !== 'error'">The catalogue could not be loaded - it needs an administrator, and a manager that is reachable.</p>
|
|
@@ -98,4 +108,4 @@ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"
|
|
|
98
108
|
<p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
|
|
99
109
|
<div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
|
|
100
110
|
</div>
|
|
101
|
-
</div>`)),
|
|
111
|
+
</div>`)),oe.querySelector(".plx-catalogue").innerHTML=ft;const Ee=b.querySelector(".plx-installed");Ee.innerHTML=bt;const Se=b.querySelector(".plx-tools");Se.innerHTML=vt;const Me=E.tabs(b.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),He=M.applyBindings({plugins:U,tools:q,toolRows:pe,catalogue:v,loading:me,loadError:te,restarting:m,filter:T,query:W,rows:ae,counts:Fe,emptyText:De,updateCount:Y,updateCountText:M.computed(()=>`${Y.value} update${Y.value===1?"":"s"}`),browseFilter:R,browseQuery:ve,browseRows:ne,browseCounts:qe,siteText:M.computed(()=>v.value.site?`Licensed to ${v.value.site}.`:""),browseState:M.computed(()=>v.value.available===null?"loading":v.value.failed?"error":v.value.available?"list":"file")},b,{methods:{showAll:()=>{T.value="all"},showOn:()=>{T.value="on"},showOff:()=>{T.value="off"},showAttention:()=>{T.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});g.add(()=>He.dispose()),g.add(Oe(b,{position:"bottom"})),g.add(M.effect(()=>{ae.value,pe.value,ne.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})})),ie(Ee,"name",Pe,e=>le(e)),ie(Se,"tool",Ke,Ve),ie(oe.querySelector(".plx-catalogue"),"slug",We,_e),Ie(A,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>P.click()}),Ie(A,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>$e()});const Ce=e=>{(m.peek()||L?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ce),g.add(()=>window.removeEventListener("beforeunload",Ce)),g.add(()=>{if(L?.dirty())try{S.set(ee,{kind:L.kind,name:L.name,values:L.values(),at:new Date().toISOString()})}catch{}we=!0,[...Q].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(b),await Promise.all([se(),fe()]),!$())return;let y=null;try{y=S.get(ee)}catch{}if(y?.values&&!we){try{S.remove?S.remove(ee):S.set(ee,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${c(J(y.name)?.displayName||y.name)}`;(y.kind==="new"||J(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&(y.kind==="new"?$e(y.values):le(y.name,y.values))}}};
|