domma-cms 0.87.0 → 0.88.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CLAUDE.md +9 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/http-interceptor.js +1 -1
  6. package/admin/js/lib/login-arrange.js +1 -0
  7. package/admin/js/lib/password-field.js +1 -0
  8. package/admin/js/lib/password-reset-actions.js +12 -0
  9. package/admin/js/lib/plugins-arrange.js +2 -2
  10. package/admin/js/lib/site-settings-arrange.js +1 -1
  11. package/admin/js/lib/users-arrange.js +1 -1
  12. package/admin/js/templates/login.html +94 -44
  13. package/admin/js/templates/plugins.html +7 -0
  14. package/admin/js/templates/settings.html +6 -0
  15. package/admin/js/templates/user-editor.html +7 -0
  16. package/admin/js/views/login.js +1 -11
  17. package/admin/js/views/plugins.js +17 -7
  18. package/admin/js/views/settings.js +4 -4
  19. package/admin/js/views/user-editor.js +1 -1
  20. package/admin/js/views/users.js +3 -2
  21. package/package.json +1 -1
  22. package/plugins/free-tier.lock.json +13 -12
  23. package/plugins/mail-reader/CLAUDE.md +5 -0
  24. package/plugins/mail-reader/plugin.json +5 -2
  25. package/plugins/security/CLAUDE.md +7 -0
  26. package/plugins/security/admin/lib/resets.js +82 -0
  27. package/plugins/security/admin/lib/settings.js +1 -1
  28. package/plugins/security/admin/templates/security.html +3 -0
  29. package/plugins/security/admin/views/security.js +23 -10
  30. package/plugins/security/plugin.js +39 -1
  31. package/plugins/security/plugin.json +2 -2
  32. package/plugins/security/tests/security.test.js +34 -0
  33. package/server/routes/api/auth.js +41 -63
  34. package/server/routes/api/forms.js +3 -0
  35. package/server/routes/api/plugins.js +33 -3
  36. package/server/routes/api/related.js +6 -3
  37. package/server/routes/api/settings.js +12 -0
  38. package/server/routes/api/tools.js +116 -0
  39. package/server/routes/api/users.js +49 -2
  40. package/server/server.js +27 -8
  41. package/server/services/notification-sources.js +21 -1
  42. package/server/services/passwordReset.js +507 -0
  43. package/server/services/plugins.js +27 -1
  44. package/server/services/renderer.js +5 -2
  45. package/server/services/siteGitignore.js +114 -3
  46. package/server/services/tools.js +259 -0
  47. package/server/services/users.js +8 -0
@@ -1,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,a){const i=l=>String(l??"").split(".").map(r=>parseInt(r,10)||0),t=i(e),n=i(a);for(let l=0;l<Math.max(t.length,n.length);l++){const r=(t[l]??0)-(n[l]??0);if(r)return r>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?{key:"on",text:"On",tone:"ok",running:!0}:{key:"off",text:"Off",tone:"muted",running:!1}}export function updateFor(e,a){const i=a?.get?.(e.name);return i&&compareVersions(i,e.version)>0?i:""}export function needsAttention(e,a){return pluginStatus(e).key==="blocked"||e.entitlement==="grace"||!!updateFor(e,a)}export function fmtReleased(e){if(!e||!/^\d{4}-\d{2}-\d{2}/.test(String(e)))return"";const a=new Date(e);if(Number.isNaN(a.getTime()))return"";const i=t=>String(t).padStart(2,"0");return`${i(a.getDate())}.${i(a.getMonth()+1)}.${a.getFullYear()}`}export function arrangePlugins(e,{filter:a="all",query:i="",latest:t=new Map}={}){const n=String(i).trim().toLowerCase(),l={all:()=>!0,on:s=>pluginStatus(s).running,off:s=>!pluginStatus(s).running,attention:s=>needsAttention(s,t)}[a]||(()=>!0),r=s=>needsAttention(s,t)?0:pluginStatus(s).running?1:2;return[...e||[]].filter(l).filter(s=>!n||[s.name,s.displayName,s.description,s.author].some(o=>String(o||"").toLowerCase().includes(n))).sort((s,o)=>r(s)-r(o)||String(s.displayName||s.name).localeCompare(String(o.displayName||o.name)))}export function pluginRow(e,a=new Map){const i=pluginStatus(e),t=c[e.entitlement]||null,n=updateFor(e,a),l=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}`:"",l?`released ${l}`:"",e.supersededBy?`replaced by ${e.supersededBy}`:""].filter(Boolean).join(" \xB7 "),description:e.description||"",statusText:i.text,statusCls:`is-${i.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,rowCls:[i.running?"":"is-off",needsAttention(e,a)?"is-attention":""].filter(Boolean).join(" ")}}export function formatPrice(e){if(e?.tier!=="paid")return"Free";const a=e.terms?.managed?.monthly;return a==null||!Number.isFinite(Number(a))?"Paid":`${e.terms?.currency==="GBP"?"\xA3":`${e.terms?.currency||""} `}${(Number(a)/100).toFixed(2)}/mo`}export function catalogueAction(e){return e.installed&&e.updatable&&e.entitled?"update":e.installed?"installed":e.entitled?"install":"locked"}export function arrangeCatalogue(e,{filter:a="all",query:i=""}={}){const t=String(i).trim().toLowerCase(),n={all:()=>!0,installed:r=>r.installed,available:r=>!r.installed&&r.entitled,updates:r=>catalogueAction(r)==="update"}[a]||(()=>!0),l={update:0,install:1,installed:2,locked:3};return[...e||[]].filter(n).filter(r=>!t||[r.slug,r.displayName,r.description].some(s=>String(s||"").toLowerCase().includes(t))).sort((r,s)=>l[catalogueAction(r)]-l[catalogueAction(s)]||String(r.displayName||r.slug).localeCompare(String(s.displayName||s.slug)))}export function catalogueRow(e){const a=catalogueAction(e),i=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:i?e.licenceStatus==="grace"?"Renew":"Licensed":"",licCls:e.licenceStatus==="grace"?"is-warn":"is-ok",action:a,actionText:{update:"Update",install:"Install",installed:"Installed",locked:e.licenceStatus==="expired"?"Licence expired":"Not licensed"}[a],rowCls:a==="locked"?"is-off":a==="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 a=String(e).replace(/[_-]+/g," ").replace(/([a-z0-9])([A-Z])/g,"$1 $2").trim().toLowerCase();return a?a[0].toUpperCase()+a.slice(1):String(e)}export function settingsFields(e){const a=e?.settings||{},i={...e?.effectiveSettings||{},...a};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",l=i[t.key]??t.default??(n==="checkbox"?!1:"");return{key:t.key,label:t.label||labelFor(t.key),type:n,value:n==="checkbox"?l===!0:String(l??""),options:(t.options||[]).map(r=>typeof r=="object"?{value:String(r.value),label:String(r.label??r.value)}:{value:String(r),label:String(r)}),min:t.min,max:t.max,help:t.help||"",secret:t.secret===!0||isSecretKey(t.key)}}):Object.entries(i).map(([t,n])=>{const l=isSecretKey(t);if(typeof n=="boolean")return{key:t,label:labelFor(t),type:"checkbox",value:n,secret:l};if(typeof n=="number")return{key:t,label:labelFor(t),type:"number",value:String(n),secret:l};if(n!==null&&typeof n=="object")return{key:t,label:labelFor(t),type:"json",value:JSON.stringify(n,null,2),secret:l};const r=String(n??"");return{key:t,label:labelFor(t),type:r.includes(`
2
- `)||r.length>80?"textarea":"text",value:r,secret:l}})}export function readSettings(e,a){const i={},t={};for(const n of e){const l=a[n.key];if(n.type==="checkbox"){!!l!=!!n.value&&(t[n.key]=!!l);continue}const r=String(l??"");if(r!==String(n.value))if(n.type==="number"){const s=Number(r);if(r.trim()===""||!Number.isFinite(s)){i[n.key]="A number, please.";continue}if(n.min!=null&&s<n.min){i[n.key]=`At least ${n.min}.`;continue}if(n.max!=null&&s>n.max){i[n.key]=`At most ${n.max}.`;continue}t[n.key]=s}else if(n.type==="json")try{t[n.key]=JSON.parse(r)}catch{i[n.key]="Not valid JSON."}else t[n.key]=r}return{errors:i,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,a=[]){const i=r=>String(e?.[r]??"").trim(),t={displayName:i("displayName"),slug:i("slug"),description:i("description"),author:i("author"),icon:i("icon")||"box"},n={},l=/[<>"\\]|\*\//;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)?a.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 r of["displayName","description","author"])!n[r]&&l.test(t[r])&&(n[r]='No <, >, ", backslash or */.');return{errors:n,payload:t}}
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 t=(e,c="")=>e==null?c:String(e),r=(e,c)=>{const s=Number.parseInt(e,10);return Number.isFinite(s)?s:c};export function homeValue(e){return/^\/[A-Za-z0-9\-_/]*$/.test(e||"")&&String(e).replace(/\/+$/,"")||"/"}export function formFromSettings(e={},c=""){const s=e.backToTop||{},o=e.cookieConsent||{},i=e.breadcrumbs||{};return{title:t(e.title),tagline:t(e.tagline),adminHome:homeValue(e.adminHome),spacerSize:t(e.layoutOptions?.spacerSize??40),seoTitle:t(e.seo?.defaultTitle),seoSeparator:t(e.seo?.titleSeparator," | "),seoDescription:t(e.seo?.defaultDescription),footerCopyright:t(e.footer?.copyright),footerMotionToggle:e.footer?.showMotionToggle!==!1,socialTwitter:t(e.social?.twitter),socialFacebook:t(e.social?.facebook),socialInstagram:t(e.social?.instagram),socialLinkedin:t(e.social?.linkedin),socialGithub:t(e.social?.github),socialYoutube:t(e.social?.youtube),smtpHost:t(e.smtp?.host),smtpPort:t(e.smtp?.port||587),smtpUser:t(e.smtp?.user),smtpPass:"",smtpSecure:e.smtp?.secure===!0,smtpFromAddress:t(e.smtp?.fromAddress),smtpFromName:t(e.smtp?.fromName),bttEnabled:s.enabled!==!1,bttSmooth:s.smooth!==!1,bttLabel:t(s.label),bttThreshold:t(s.scrollThreshold??300),bttPosition:t(s.position,"bottom-right"),bttOffset:t(s.offset??32),bttBottomOffset:t(s.bottomOffset??s.offset??32),ccEnabled:o.enabled!==!1,ccMessage:t(o.message),ccAcceptAll:t(o.acceptAllText,"Accept All"),ccRejectAll:t(o.rejectAllText,"Reject All"),ccCustomize:t(o.customizeText,"Customize"),ccSavePrefs:t(o.savePreferencesText,"Save Preferences"),ccPrivacyText:t(o.privacyPolicyText,"Privacy Policy"),ccPrivacyUrl:t(o.privacyPolicyUrl),ccCookieText:t(o.cookiePolicyText,"Cookie Policy"),ccCookieUrl:t(o.cookiePolicyUrl),ccPosition:t(o.position,"bottom"),ccLayout:t(o.layout,"bar"),ccTheme:t(o.theme,"dark"),ccShowFunctional:o.showFunctional!==!1,ccShowAnalytics:o.showAnalytics!==!1,ccShowMarketing:o.showMarketing!==!1,ccVersion:t(o.consentVersion,"1.0"),bcEnabled:i.enabled===!0,bcHomeLabel:t(i.homeLabel,"Home"),bcPosition:BREADCRUMB_POSITIONS.includes(i.position)?i.position:"TL",bcOffsetX:t(i.offsetX??8),bcOffsetY:t(i.offsetY??8),css:t(c)}}export function payloadFromForm(e,c={},{adminHomeReady:s=!0}={}){const o=i=>t(i).trim();return{title:o(e.title),tagline:o(e.tagline),...s&&{adminHome:o(e.adminHome)||"/"},layoutOptions:{spacerSize:r(e.spacerSize,40)||40},seo:{defaultTitle:o(e.seoTitle),titleSeparator:t(e.seoSeparator)||" | ",defaultDescription:o(e.seoDescription)},footer:{copyright:o(e.footerCopyright),showMotionToggle:e.footerMotionToggle===!0,links:c.footer?.links||[]},social:{twitter:o(e.socialTwitter),facebook:o(e.socialFacebook),instagram:o(e.socialInstagram),linkedin:o(e.socialLinkedin),github:o(e.socialGithub),youtube:o(e.socialYoutube)},smtp:{host:o(e.smtpHost),port:r(e.smtpPort,587)||587,user:o(e.smtpUser),pass:t(e.smtpPass),secure:e.smtpSecure===!0,fromAddress:o(e.smtpFromAddress),fromName:o(e.smtpFromName)},backToTop:{enabled:e.bttEnabled===!0,scrollThreshold:r(e.bttThreshold,300),position:t(e.bttPosition)||"bottom-right",offset:r(e.bttOffset,32),bottomOffset:r(e.bttBottomOffset,32),label:o(e.bttLabel),smooth:e.bttSmooth===!0},cookieConsent:{enabled:e.ccEnabled===!0,message:o(e.ccMessage),acceptAllText:o(e.ccAcceptAll),rejectAllText:o(e.ccRejectAll),customizeText:o(e.ccCustomize),savePreferencesText:o(e.ccSavePrefs),privacyPolicyText:o(e.ccPrivacyText),privacyPolicyUrl:o(e.ccPrivacyUrl),cookiePolicyText:o(e.ccCookieText),cookiePolicyUrl:o(e.ccCookieUrl),position:t(e.ccPosition)||"bottom",layout:t(e.ccLayout)||"bar",theme:t(e.ccTheme)||"dark",showFunctional:e.ccShowFunctional===!0,showAnalytics:e.ccShowAnalytics===!0,showMarketing:e.ccShowMarketing===!0,consentVersion:o(e.ccVersion)||"1.0"},breadcrumbs:{enabled:e.bcEnabled===!0,homeLabel:o(e.bcHomeLabel)||"Home",position:BREADCRUMB_POSITIONS.includes(e.bcPosition)?e.bcPosition:"TL",offsetX:r(e.bcOffsetX,8),offsetY:r(e.bcOffsetY,8)}}}const a=e=>typeof e=="boolean"?e:t(e);export function snapshot(e){return JSON.stringify(Object.keys(e).sort().map(c=>[c,a(e[c])]))}export function changedTabs(e,c){const s=[];for(const[o,i]of Object.entries(TAB_FIELDS))i.some(n=>a(e[n])!==a(c[n]))&&s.push(o);return s}export function draftOf(e){return Object.fromEntries(Object.entries(e).filter(([c])=>!SECRET_FIELDS.includes(c)))}export function settingsProblems(e){const c=[];return t(e.smtpHost).trim()?t(e.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)."}),e.ccEnabled===!0&&!t(e.ccPrivacyUrl).trim()&&c.push({tone:"warn",tab:"cookies",text:"The cookie banner is on but links to no privacy policy."}),c}export function describeCacheKey(e){const c=/^page:(.*?):m\d+:role([^:]*):/.exec(String(e||""));return c?{path:c[1]||"/",role:c[2]||"anon",page:!0}:{path:String(e||""),role:"",page:!1}}export function expiresIn(e,c=Date.now()){if(e==null)return"no expiry";const s=e-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
+ 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 f={"badge-danger":"danger","badge-warning":"warn","badge-success":"ok","badge-primary":"primary","badge-info":"primary"};export function roleIndex(e=[]){const t={};for(const a of e||[]){const i=a?.data||a||{};i.name&&(t[i.name]={label:i.label||i.name,level:Number.isFinite(i.level)?i.level:99,tone:f[i.badgeClass]||""})}return t}const d=(e,t)=>e[t]?.level??99;export function canManage(e,t,a={}){const i=!!e?.id&&e.id===t?.id,o=d(a,e?.role)<d(a,t?.role);return{self:i,edit:i||o,other:!i&&o}}export function initials(e){const t=i=>(String(i).match(/[\p{L}\p{N}]/u)||[""])[0],a=String(e?.name||"").split(/\s+/).map(t).filter(Boolean);return a.length?(a[0]+(a.length>1?a.at(-1):"")).toUpperCase():(t(e?.email||"")||"?").toUpperCase()}export function arrangeUsers(e,{filter:t="all",role:a="",query:i="",sort:o="name"}={}){const l=String(i).trim().toLowerCase(),c=(e||[]).filter(n=>t==="active"&&n.isActive===!1||t==="inactive"&&n.isActive!==!1||a&&n.role!==a&&!(Array.isArray(n.additionalRoles)&&n.additionalRoles.includes(a))?!1:l?[n.name,n.email,n.role,...n.additionalRoles||[],...n.projects||[]].some(r=>String(r||"").toLowerCase().includes(l)):!0),s=(n,r)=>String(n.name||n.email||"").localeCompare(String(r.name||r.email||""));return o==="recent"?c.sort((n,r)=>(n.lastLogin?0:1)-(r.lastLogin?0:1)||String(r.lastLogin||"").localeCompare(String(n.lastLogin||""))||s(n,r)):c.sort(s)}export function userRow(e,{roles:t={},me:a=null,when:i=o=>o}={}){const o=canManage(a,e,t),l=t[e.role]||{label:e.role||"no role",tone:""},c=(Array.isArray(e.additionalRoles)?e.additionalRoles:[]).filter(n=>n&&n!==e.role).map(n=>t[n]?.label||n),s=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 ${i(e.lastLogin)}`:"never signed in"].filter(Boolean).join(" \xB7 "),roleLabel:l.label,roleTone:l.tone?`is-${l.tone}`:"",extraRoles:c.join(", "),scope:s.length?`Access: ${s.join(", ")}`:"",inactive:e.isActive===!1,self:o.self,locked:!o.edit,rowCls:[e.isActive===!1&&"is-off",!o.edit&&"is-locked"].filter(Boolean).join(" ")}}
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(" ")}}
@@ -1,106 +1,156 @@
1
+ <!-- Sign in, first-time setup, onboarding and password reset. Bound with M.applyBindings - see
2
+ admin/js/views/login.js: `step` says which panel shows, and nothing here is shown or hidden by hand.
3
+ The F.render forms (setup, login, forgot, reset) are drawn into their containers as before. -->
1
4
  <div class="login-wrap">
2
5
  <div class="login-card card">
3
6
  <div class="card-body">
4
7
  <div class="login-logo">
5
8
  <span data-icon="layout"></span>
6
- <h1>Domma CMS</h1>
9
+ <h1 data-bind-text="siteTitle.value">Domma CMS</h1>
7
10
  </div>
8
11
 
9
12
  <!-- ── Step 1: Setup (first-time - no users exist) ── -->
10
- <div id="setup-panel" style="display:none">
13
+ <div hidden data-bind-hidden="step.value !== 'setup'">
11
14
  <h2 class="login-heading">Welcome - create your admin account</h2>
12
15
  <p class="text-muted mb-4">This is a first-time setup. Set up your administrator account to get started.</p>
13
16
  <div id="setup-form-container"></div>
14
17
  </div>
15
18
 
16
19
  <!-- ── Step 2: Onboarding - Site Identity ── -->
17
- <div id="onboarding-site-panel" style="display:none">
20
+ <div hidden data-bind-hidden="step.value !== 'onboarding-site'">
18
21
  <h2 class="login-heading">Name your site</h2>
19
22
  <p class="text-muted mb-4">You can change these any time from Settings.</p>
20
23
  <div class="mb-3">
21
24
  <label class="form-label" for="ob-title">Site title</label>
22
- <input type="text" id="ob-title" class="form-input" placeholder="My Blog">
25
+ <input type="text" id="ob-title" class="form-input" placeholder="My Blog" data-model="obTitle.value">
23
26
  </div>
24
27
  <div class="mb-4">
25
28
  <label class="form-label" for="ob-tagline">Tagline <span class="text-muted">(optional)</span></label>
26
- <input type="text" id="ob-tagline" class="form-input" placeholder="A short description of your site">
29
+ <input type="text" id="ob-tagline" class="form-input" placeholder="A short description of your site" data-model="obTagline.value">
27
30
  </div>
28
- <div id="ob-site-error" class="alert alert-danger mb-3" style="display:none"></div>
29
- <button id="ob-site-btn" class="btn btn-primary btn-block">Continue</button>
30
- <a href="#" id="ob-site-skip" class="btn-skip">Skip this step</a>
31
+ <div class="alert alert-danger mb-3" hidden data-bind-hidden="!obSiteError.value" data-bind-text="obSiteError.value"></div>
32
+ <button class="btn btn-primary btn-block" data-on-click="saveSite" data-bind-disabled="obSiteSaving.value"
33
+ data-bind-text="obSiteSaving.value ? 'Saving…' : 'Continue'">Continue</button>
34
+ <a href="#" class="btn-skip" data-on-click="skipSite">Skip this step</a>
31
35
  </div>
32
36
 
33
37
  <!-- ── Step 3: Onboarding - Theme Picker ── -->
34
- <div id="onboarding-theme-panel" style="display:none">
38
+ <div hidden data-bind-hidden="step.value !== 'onboarding-theme'">
35
39
  <h2 class="login-heading">Choose a theme</h2>
36
40
  <p class="text-muted mb-3">Pick the colour scheme for your site. You can change it later.</p>
37
- <div class="theme-grid" id="theme-grid"></div>
38
- <div id="ob-theme-error" class="alert alert-danger mb-3" style="display:none"></div>
39
- <button id="ob-theme-btn" class="btn btn-primary btn-block mt-3">Apply theme</button>
40
- <a href="#" id="ob-theme-skip" class="btn-skip">Skip this step</a>
41
+ <div class="theme-grid" data-each="themes key=id">
42
+ <div class="theme-swatch" data-bind-title="id" data-on-click="$parent.pickTheme(id)"
43
+ data-bind-class="$parent.theme.value === id && 'selected'">
44
+ <div class="theme-swatch-preview" data-bind-style-background="bg">
45
+ <div class="theme-swatch-accent" data-bind-style-background="primary"></div>
46
+ </div>
47
+ <div class="theme-swatch-label">
48
+ <span data-bind-text="label"></span>
49
+ <span class="theme-swatch-mode" data-bind-text="dark ? 'Dark' : 'Light'"></span>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ <div class="alert alert-danger mb-3" hidden data-bind-hidden="!obThemeError.value" data-bind-text="obThemeError.value"></div>
54
+ <button class="btn btn-primary btn-block mt-3" data-on-click="saveTheme" data-bind-disabled="obThemeSaving.value"
55
+ data-bind-text="obThemeSaving.value ? 'Applying…' : 'Apply theme'">Apply theme</button>
56
+ <a href="#" class="btn-skip" data-on-click="skipTheme">Skip this step</a>
41
57
  </div>
42
58
 
43
59
  <!-- ── Step 4: Onboarding - Done ── -->
44
- <div id="onboarding-done-panel" style="display:none">
60
+ <div hidden data-bind-hidden="step.value !== 'onboarding-done'">
45
61
  <div class="ob-done-icon"><span data-icon="check-circle"></span></div>
46
62
  <h2 class="login-heading">You're all set!</h2>
47
63
  <p class="text-muted mb-4">Your site is ready. Head to the dashboard to start creating content.</p>
48
- <a href="#/" id="ob-go-btn" class="btn btn-primary btn-block">Go to dashboard</a>
64
+ <a href="#/" class="btn btn-primary btn-block">Go to dashboard</a>
49
65
  </div>
50
66
 
51
67
  <!-- ── Login mode ── -->
52
- <div id="login-panel" style="display:none">
68
+ <div hidden data-bind-hidden="step.value !== 'login'">
53
69
  <h2 class="login-heading">Sign in</h2>
70
+ <div class="alert alert-info mb-3" hidden data-bind-hidden="!notice.value" data-bind-text="notice.value"></div>
54
71
  <div id="login-form-container"></div>
55
- <a href="#" id="forgot-link" class="btn-skip">Forgot your password?</a>
72
+ <a href="#" class="btn-skip" data-on-click="forgot">Forgot your password?</a>
56
73
  </div>
57
74
 
58
- <!-- ── A sign-in check asks one more thing (two-factor) ── -->
59
- <div id="challenge-panel" style="display:none">
60
- <h2 class="login-heading" id="challenge-title">One more step</h2>
61
- <p class="text-muted mb-4" id="challenge-help"></p>
62
- <div class="login-enrol" id="challenge-enrol" style="display:none">
63
- <img id="challenge-image" alt="QR code to scan with your authenticator app" width="180" height="180">
75
+ <!-- ── A sign-in check asks one more thing (two-factor). Plugin text: bound as text, never markup. ── -->
76
+ <div hidden data-bind-hidden="step.value !== 'challenge'">
77
+ <h2 class="login-heading" data-bind-text="chTitle.value">One more step</h2>
78
+ <p class="text-muted mb-4" data-bind-text="chHelp.value"></p>
79
+ <div class="login-enrol" hidden data-bind-hidden="!(chSecret.value || chImage.value)">
80
+ <!-- Only a data: image reaches here (the server checks it). -->
81
+ <img alt="QR code to scan with your authenticator app" width="180" height="180"
82
+ hidden data-bind-hidden="!chImage.value" data-bind-src="chImage.value">
64
83
  <div class="login-secret">
65
84
  <small class="text-muted">Or type this key into the app:</small>
66
- <code id="challenge-secret"></code>
67
- <button type="button" class="btn btn-xs btn-ghost" id="challenge-copy">Copy</button>
85
+ <code data-bind-text="chSecret.value"></code>
86
+ <button type="button" class="btn btn-xs btn-ghost" data-on-click="copySecret">Copy</button>
68
87
  </div>
69
88
  </div>
70
- <form id="challenge-form" class="login-challenge" autocomplete="off">
71
- <label class="form-label" for="challenge-code" id="challenge-label">Code</label>
72
- <input id="challenge-code" class="form-input" autocomplete="one-time-code" inputmode="numeric" maxlength="64" required>
73
- <label class="login-trust" id="challenge-trust-wrap" style="display:none">
74
- <input type="checkbox" id="challenge-trust"> Trust this browser for 30 days</label>
75
- <p class="text-danger" id="challenge-error" style="display:none"></p>
76
- <button type="submit" class="btn btn-primary btn-block" id="challenge-submit">Verify</button>
89
+ <form class="login-challenge" autocomplete="off" data-on-submit="verify">
90
+ <label class="form-label" for="challenge-code" data-bind-text="chLabel.value">Code</label>
91
+ <input id="challenge-code" class="form-input" autocomplete="one-time-code" maxlength="64" required
92
+ data-model="chCode.value" data-bind-inputmode="chInputMode.value">
93
+ <label class="login-trust" hidden data-bind-hidden="!chCanTrust.value">
94
+ <input type="checkbox" data-model="chTrust.value"> Trust this browser for 30 days</label>
95
+ <p class="text-danger" hidden data-bind-hidden="!chError.value" data-bind-text="chError.value"></p>
96
+ <button type="submit" class="btn btn-primary btn-block" data-bind-disabled="chBusy.value">Verify</button>
77
97
  </form>
78
- <a href="#" id="challenge-recovery" class="btn-skip" style="display:none">Use a recovery code instead</a>
79
- <a href="#" id="challenge-back" class="btn-skip">Back to sign in</a>
98
+ <a href="#" class="btn-skip" hidden data-bind-hidden="!chCanRecover.value" data-on-click="toggleRecovery"
99
+ data-bind-text="chMode.value === 'recovery' ? 'Use the code instead' : 'Use a recovery code instead'">Use a recovery code instead</a>
100
+ <a href="#" class="btn-skip" data-on-click="backToLogin">Back to sign in</a>
80
101
  </div>
81
102
 
82
103
  <!-- ── Forgot password ── -->
83
- <div id="forgot-panel" style="display:none">
104
+ <div hidden data-bind-hidden="step.value !== 'forgot'">
84
105
  <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
- <div id="forgot-form-container"></div>
87
- <a href="#" id="forgot-back-link" class="btn-skip">Back to sign in</a>
106
+ <p class="text-muted mb-4" data-bind-hidden="!byEmail.value">Enter your email and we'll send you a reset link.</p>
107
+ <div id="forgot-form-container" data-bind-hidden="!byEmail.value"></div>
108
+ <!-- No mail server: say so rather than promise an email that never comes. -->
109
+ <div class="alert alert-warning mb-3" hidden data-bind-hidden="byEmail.value">
110
+ This site cannot send email, so it cannot send you a reset link. Ask an administrator to set a
111
+ new password for you.
112
+ </div>
113
+ <a href="#" class="btn-skip" data-on-click="backToLogin">Back to sign in</a>
88
114
  </div>
89
115
 
90
116
  <!-- ── Forgot success ── -->
91
- <div id="forgot-success-panel" style="display:none">
117
+ <div hidden data-bind-hidden="step.value !== 'forgot-success'">
92
118
  <div class="ob-done-icon"><span data-icon="mail"></span></div>
93
119
  <h2 class="login-heading">Check your email</h2>
94
120
  <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>
96
- <a href="#" id="forgot-success-back-link" class="btn btn-primary btn-block">Back to sign in</a>
121
+ <span data-bind-text="expiresIn.value || '1 hour'">1 hour</span>. Nothing there? Check your spam folder.</p>
122
+ <a href="#" class="btn btn-primary btn-block" data-on-click="backToLogin">Back to sign in</a>
97
123
  </div>
98
124
 
99
- <!-- ── Reset password ── -->
100
- <div id="reset-panel" style="display:none">
125
+ <!-- ── Reset password (the link was checked on opening) ── -->
126
+ <div hidden data-bind-hidden="step.value !== 'reset'">
101
127
  <h2 class="login-heading">Set a new password</h2>
102
- <p class="text-muted mb-4">Choose a strong password of at least 8 characters.</p>
128
+ <p class="text-muted mb-3">For <strong data-bind-text="resetEmail.value"></strong>. This link works once and
129
+ expires in <span data-bind-text="resetExpiry.value"></span>.</p>
103
130
  <div id="reset-form-container"></div>
131
+ <ul class="login-rules" aria-live="polite" hidden data-bind-hidden="!resetLines.value.length"
132
+ data-each="resetLines.value key=id"><li data-bind-text="text"></li></ul>
133
+ <p class="text-muted login-small">Setting it signs this account out everywhere else, and we email you
134
+ to say it changed.</p>
135
+ </div>
136
+
137
+ <!-- ── The reset link is no good ── -->
138
+ <div hidden data-bind-hidden="step.value !== 'reset-invalid'">
139
+ <div class="ob-done-icon login-icon-warn"><span data-icon="clock"></span></div>
140
+ <h2 class="login-heading">This link has expired or was already used</h2>
141
+ <p class="text-muted mb-4">Reset links work once and only for a limited time. A newer link also
142
+ replaces an older one - use the most recent email. You can ask for a new link now.</p>
143
+ <a href="#" class="btn btn-primary btn-block" data-on-click="newLink">Send me a new link</a>
144
+ <a href="#/login" class="btn-skip">Back to sign in</a>
145
+ </div>
146
+
147
+ <!-- ── Reset done ── -->
148
+ <div hidden data-bind-hidden="step.value !== 'reset-done'">
149
+ <div class="ob-done-icon"><span data-icon="check-circle"></span></div>
150
+ <h2 class="login-heading">Password changed</h2>
151
+ <p class="text-muted mb-4">Sign in with your new password. Anywhere else you were signed in has been
152
+ signed out.</p>
153
+ <a href="#" class="btn btn-primary btn-block" data-on-click="signInAfterReset">Sign in</a>
104
154
  </div>
105
155
  </div>
106
156
  </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
@@ -1,11 +1 @@
1
- import{api as r,isAuthenticated as k,setAuthData as p,takePendingLogout as x}from"../api.js";import{themesInDisplayOrder as C}from"../lib/themes.js";const S={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"}}},P={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){if(x(),k()){R.navigate("/");return}const t=window.location.hash,s=t.match(/[?&]token=([a-f0-9]{64})/);if(t.startsWith("#/reset-password")&&s){i(e,"reset"),O(e,s[1]),Domma.icons.scan();return}if(t.startsWith("#/reset-password")){R.navigate("/login");return}let o=!1;const a=E.loader(e.get(0),{type:"dots"});try{o=(await r.auth.setupStatus()).needsSetup}catch{E.toast("Could not reach the server.",{type:"error"})}finally{a.destroy()}o?(i(e,"setup"),q(e)):(i(e,"login"),M(e)),Domma.icons.scan()}};const D={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}}},T={password:{type:"password",required:!0,minLength:8,label:"New Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Minimum 8 characters"}},confirmPassword:{type:"password",required:!0,label:"Confirm Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password"}}},L=["setup","onboarding-site","onboarding-theme","onboarding-done","login","forgot","forgot-success","reset","challenge"];function i(e,t){L.forEach(s=>e.find(`#${s}-panel`).hide()),e.find(`#${t}-panel`).show(),Domma.icons.scan()}function h(e,t,s){e.find(`#${t}`).text(s).show()}function q(e){F.render("#setup-form-container",S,{},{layout:"stacked",submitText:"Create admin account",onSubmit:async t=>{try{const s=await r.auth.setup(t);p(s),i(e,"onboarding-site"),A(e)}catch(s){return E.toast(s.message||"Setup failed. Please try again.",{type:"error"}),!1}}})}function A(e){e.find("#ob-site-skip").on("click",t=>{t.preventDefault(),i(e,"onboarding-theme"),y(e),b(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 a=await r.settings.get();await r.settings.save({...a,title:t||a.title,tagline:s||a.tagline,seo:{...a.seo||{},defaultTitle:t||a.seo&&a.seo.defaultTitle}});const l=await r.navigation.get();await r.navigation.save({...l,brand:{...l.brand||{},text:t||l.brand.text}}),i(e,"onboarding-theme"),y(e),b(e)}catch(a){h(e,"ob-site-error",a.message||"Could not save site details.")}finally{o.prop("disabled",!1).text("Continue")}})}function y(e){const t=e.find("#theme-grid").empty();C().forEach(s=>{const o=$(`
2
- <div class="theme-swatch" data-theme="${s.id}" title="${s.id}">
3
- <div class="theme-swatch-preview" style="background:${s.bg}">
4
- <div class="theme-swatch-accent" style="background:${s.primary}"></div>
5
- </div>
6
- <div class="theme-swatch-label">
7
- <span>${s.label}</span>
8
- <span class="theme-swatch-mode">${s.dark?"Dark":"Light"}</span>
9
- </div>
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 b(e){e.find("#ob-theme-skip").on("click",t=>{t.preventDefault(),i(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 r.settings.get();await r.settings.save({...o,theme:t}),i(e,"onboarding-done")}catch(o){h(e,"ob-theme-error",o.message||"Could not save theme.")}finally{s.prop("disabled",!1).text("Apply theme")}})}function M(e){F.render("#login-form-container",P,{},{layout:"stacked",submitText:"Sign in",onSubmit:async t=>{try{const s=await r.auth.login(t);if(s?.challenge&&s.ticket)return w(e,s),!1;p(s),R.navigate("/")}catch(s){return E.toast(s.message||"Invalid credentials. Please try again.",{type:"error"}),!1}}}),e.find("#forgot-link").on("click",t=>{t.preventDefault(),i(e,"forgot"),B(e)})}function w(e,{challenge:t,ticket:s}){let o="code";const a=e.find("#challenge-code"),l=e.find("#challenge-error"),u=e.find("#challenge-recovery"),f=n=>{o=n;const d=n==="recovery";e.find("#challenge-label").text(d?"Recovery code":t.label||"Code"),e.find("#challenge-help").text(d?"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."),a.val("").attr("inputmode",d||t.inputMode==="text"?"text":"numeric"),u.text(d?"Use the code instead":"Use a recovery code instead"),l.hide(),setTimeout(()=>a.trigger("focus"),0)};e.find("#challenge-title").text(t.title||"One more step");const v=!!(t.image||t.secret);e.find("#challenge-enrol").css("display",v?"":"none");const m=e.find("#challenge-image").get(0);m&&(m.src=t.image||"",m.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"),f("code"),i(e,"challenge"),u.off("click").on("click",n=>{n.preventDefault(),f(o==="code"?"recovery":"code")}),e.find("#challenge-back").off("click").on("click",n=>{n.preventDefault(),i(e,"login")}),e.find("#challenge-form").off("submit").on("submit",async n=>{n.preventDefault();const d=e.find("#challenge-submit").prop("disabled",!0);try{const c=await r.auth.verifyLogin({ticket:s,response:String(a.val()||"").trim(),mode:o,trust:e.find("#challenge-trust").prop("checked")===!0});if(c?.challenge&&c.ticket){w(e,c);return}p(c),R.navigate("/")}catch(c){const g=c.message||"That did not match.";l.text(g).show(),/sign in again/i.test(g)&&setTimeout(()=>i(e,"login"),1800)}finally{d.prop("disabled",!1)}})}function B(e){F.render("#forgot-form-container",D,{},{layout:"stacked",submitText:"Send reset link",onSubmit:async s=>{try{await r.auth.forgotPassword(s.email)}catch{}i(e,"forgot-success"),Domma.icons.scan()}});const t=s=>{s.preventDefault(),i(e,"login")};e.find("#forgot-back-link").on("click",t),e.find("#forgot-success-back-link").on("click",t)}function O(e,t){F.render("#reset-form-container",T,{},{layout:"stacked",submitText:"Set new password",onSubmit:async s=>{if(s.password!==s.confirmPassword)return E.toast("Passwords do not match.",{type:"error"}),!1;try{await r.auth.resetPassword(t,s.password),E.toast("Password updated. Please sign in.",{type:"success"}),window.location.hash="#/login"}catch(o){return E.toast(o.message||"Link expired or invalid.",{type:"error"}),!1}}})}
1
+ import{api as i,isAuthenticated as B,setAuthData as f,takePendingLogout as A}from"../api.js";import{themesInDisplayOrder as D}from"../lib/themes.js";import{enhancePasswordFields as g}from"../lib/password-field.js";import{challengeText as b,minutesText as O,passwordVerdict as j,readLoginHash as H,resetTokenFrom as N}from"../lib/login-arrange.js";import{viewLifetime as V}from"../lib/tool-kit.js";const W={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"}}},Y={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"}}},K={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}}},U={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 S=Promise.resolve();function z(){let e;S=new Promise(o=>{e=o});const t=M.subscribe("router:afterChange",()=>{a(),e()}),a=()=>{typeof t=="function"&&t()};setTimeout(()=>{a(),e()},1500)}function y(e){S.then(()=>R.navigate(e))}const d=V(/^#\/(login|reset-password)(\?|$)/);function G(){const e=M.observable(null),t=M.observable("code");return{step:M.observable(""),siteTitle:M.observable("Domma CMS"),notice:M.observable(""),byEmail:M.observable(!0),expiresIn:M.observable(""),obTitle:M.observable(""),obTagline:M.observable(""),obSiteError:M.observable(""),obSiteSaving:M.observable(!1),themes:D(),theme:M.observable("charcoal-dark"),obThemeError:M.observable(""),obThemeSaving:M.observable(!1),chChallenge:e,chMode:t,chTitle:M.computed(()=>e.value?.title||"One more step"),chLabel:M.computed(()=>b(e.value,t.value).label),chHelp:M.computed(()=>b(e.value,t.value).help),chInputMode:M.computed(()=>b(e.value,t.value).inputMode),chImage:M.computed(()=>e.value?.image||""),chSecret:M.computed(()=>e.value?.secret||""),chCanTrust:M.computed(()=>e.value?.trust===!0),chCanRecover:M.computed(()=>e.value?.recovery===!0),chCode:M.observable(""),chTrust:M.observable(!1),chError:M.observable(""),chBusy:M.observable(!1),resetEmail:M.observable(""),resetExpiry:M.observable(""),resetLines:M.observable([])}}function w(e,t={}){e.byEmail.value=t.resetByEmail!==!1,e.expiresIn.value=t.resetExpiresIn||"",t.siteTitle&&(e.siteTitle.value=t.siteTitle)}export const loginView={templateUrl:"/admin/js/templates/login.html",async onMount(e){d.reset(),z();const t=A(),a=window.location.hash,{next:o,expired:s}=H(a);if(B()){y(a.startsWith("#/reset-password")?"/":o);return}const n=e.get(0).querySelector(".login-wrap")||e.get(0),c=G(),l={state:c,root:n,next:o,token:"",email:"",pw:{problems:[],asked:0,timer:null}},p=M.applyBindings(c,n,{methods:J(l)});if(d.add(()=>p.dispose()),d.add(()=>clearTimeout(l.pw.timer)),d.add(M.effect(()=>{c.step.value,requestAnimationFrame(()=>{n.isConnected&&I.scan(n)})})),a.startsWith("#/reset-password")){await $(l,N(a));return}let h=!1;const v=E.loader(e.get(0),{type:"dots"});try{const r=await i.auth.setupStatus();h=r.needsSetup,w(c,r)}catch{E.toast("Could not reach the server.",{type:"error"})}finally{v.destroy()}h?(c.step.value="setup",Q(l)):(c.step.value="login",T(l),s&&!t&&(c.notice.value="Your session ended - sign in again to carry on where you were."))}};function J(e){const{state:t}=e,a=o=>s=>{s?.preventDefault?.(),o()};return{skipSite:a(()=>{t.step.value="onboarding-theme"}),saveSite:()=>X(t),pickTheme:o=>{t.theme.value=o},skipTheme:a(()=>{t.step.value="onboarding-done"}),saveTheme:()=>Z(t),backToLogin:a(()=>{t.step.value="login"}),forgot:a(()=>{const o=String(e.root.querySelector('#login-form-container input[type="email"]')?.value||"").trim();t.step.value="forgot",C(e,o)}),toggleRecovery:a(()=>x(t,t.chMode.value==="code"?"recovery":"code")),copySecret:async()=>{try{await navigator.clipboard.writeText(String(t.chSecret.value).replace(/\s+/g,"")),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - select the key and copy it.",{type:"warning"})}},verify:o=>{o?.preventDefault?.(),_(e)},newLink:a(async()=>{try{w(t,await i.auth.setupStatus())}catch{}t.step.value="forgot",C(e)}),signInAfterReset:a(()=>{t.step.value="login",T(e,{email:e.email})})}}function Q(e){F.render("#setup-form-container",W,{},{layout:"stacked",submitText:"Create admin account",onSubmit:async t=>{try{const a=await i.auth.setup(t);f(a),e.state.step.value="onboarding-site"}catch(a){return E.toast(a.message||"Setup failed. Please try again.",{type:"error"}),!1}}}),g(e.root.querySelector("#setup-form-container"))}async function X(e){e.obSiteError.value="";const t=e.obTitle.value.trim(),a=e.obTagline.value.trim();e.obSiteSaving.value=!0;try{const o=await i.settings.get();await i.settings.save({...o,title:t||o.title,tagline:a||o.tagline,brand:{...o.brand||{},text:t||o.brand?.text||o.title},seo:{...o.seo||{},defaultTitle:t||o.seo&&o.seo.defaultTitle}}),e.step.value="onboarding-theme"}catch(o){e.obSiteError.value=o.message||"Could not save site details."}finally{e.obSiteSaving.value=!1}}async function Z(e){e.obThemeError.value="",e.obThemeSaving.value=!0;try{await i.theme.save({theme:e.theme.value||"charcoal-dark"}),e.step.value="onboarding-done"}catch(t){e.obThemeError.value=t.message||"Could not save theme."}finally{e.obThemeSaving.value=!1}}function T(e,{email:t=""}={}){F.render("#login-form-container",Y,t?{email:t}:{},{layout:"stacked",submitText:"Sign in",onSubmit:async a=>{try{const o=await i.auth.login(a);if(o?.challenge&&o.ticket)return k(e,o),!1;f(o),y(e.next)}catch(o){return E.toast(o.message||"Invalid credentials. Please try again.",{type:"error"}),!1}}}),g(e.root.querySelector("#login-form-container"))}function k(e,{challenge:t,ticket:a}){const{state:o}=e;e.ticket=a,o.chChallenge.value=t||{},o.chTrust.value=!1,x(o,"code"),o.step.value="challenge"}function x(e,t){e.chMode.value=t,e.chCode.value="",e.chError.value="",setTimeout(()=>document.getElementById("challenge-code")?.focus(),0)}async function _(e){const{state:t}=e;t.chBusy.value=!0;try{const a=await i.auth.verifyLogin({ticket:e.ticket,response:String(t.chCode.value||"").trim(),mode:t.chMode.value,trust:t.chTrust.value===!0});if(a?.challenge&&a.ticket){k(e,a);return}f(a),y(e.next)}catch(a){const o=a.message||"That did not match.";t.chError.value=o,/sign in again/i.test(o)&&setTimeout(()=>{t.step.value="login"},1800)}finally{t.chBusy.value=!1}}function C(e,t=""){const{state:a}=e;a.byEmail.value&&F.render("#forgot-form-container",K,t?{email:t}:{},{layout:"stacked",submitText:"Send reset link",onSubmit:async o=>{try{const s=await i.auth.forgotPassword(o.email);s?.expiresIn&&(a.expiresIn.value=s.expiresIn)}catch{}a.step.value="forgot-success"}})}async function $(e,t){const{state:a}=e;if(!t){a.step.value="reset-invalid";return}const o=E.loader(e.root,{type:"dots"});let s;i.auth.setupStatus().then(n=>w(a,n)).catch(()=>{});try{s=await i.auth.checkReset(t)}catch{a.step.value="reset-invalid";return}finally{o.destroy()}e.token=t,e.email=s.email||"",a.resetEmail.value=e.email,a.resetExpiry.value=O(s.minutesLeft),a.step.value="reset",ee(e)}function ee(e){const{state:t,token:a,email:o,pw:s}=e,n=e.root.querySelector("#reset-form-container"),c=()=>({password:String(n.querySelector('input[name="password"]')?.value||""),confirm:String(n.querySelector('input[name="confirmPassword"]')?.value||"")}),l=()=>{const{password:r,confirm:m}=c(),u=j({password:r,confirm:m,problems:s.problems}),q=[...u.messages,...u.mismatch?["The two passwords do not match yet."]:[]];return t.resetLines.value=r?q.map((L,P)=>({id:P,text:L})):[],u},p=async()=>{const{password:r}=c(),m=++s.asked;if(r.length<8){s.problems=[],l();return}try{const u=await i.auth.checkReset(a,r);m===s.asked&&(s.problems=u.problems||[])}catch(u){if(u.body?.expired){t.step.value="reset-invalid";return}}m===s.asked&&l()};F.render("#reset-form-container",U,{},{layout:"stacked",submitText:"Set new password",onSubmit:async r=>{await p();const m=l();if(!m.ok)return E.toast(m.mismatch?"The passwords do not match.":"That password does not meet the rules below.",{type:"error"}),!1;try{await i.auth.resetPassword(a,r.password),history.replaceState(null,"","#/reset-password"),t.step.value="reset-done"}catch(u){return u.body?.expired?(t.step.value="reset-invalid",!1):(s.problems=u.body?.problems||[],l(),E.toast(u.message||"The password was not changed.",{type:"error"}),!1)}}});const h=n.querySelector("form")||n;if(o){const r=document.createElement("input");r.type="email",r.name="username",r.autocomplete="username",r.value=o,r.readOnly=!0,r.hidden=!0,h.prepend(r)}g(n);const v=r=>{r.target.matches?.('input[type="password"], input[name="password"], input[name="confirmPassword"]')&&(r.target.name==="password"&&(s.problems=[]),l(),clearTimeout(s.timer),r.target.name==="password"&&(s.timer=setTimeout(p,400)))};n.addEventListener("input",v),d.add(()=>n.removeEventListener("input",v))}