kempo 4.9.5 → 4.9.6

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.
@@ -5,6 +5,7 @@
5
5
  <content>
6
6
  <script type="module" src="/kempo-ui/components/Toggle.js"></script>
7
7
  <script type="module" src="/kempo-ui/components/Icon.js"></script>
8
+ <script type="module" src="/kempo-ui/components/Tabs.js"></script>
8
9
  <script type="module" src="/kempo-ui/components/Table.js"></script>
9
10
  <script type="module" src="/kempo-ui/components/controls/TcFirstPage.js"></script>
10
11
  <script type="module" src="/kempo-ui/components/controls/TcPrevPage.js"></script>
@@ -38,69 +39,71 @@
38
39
  <a href="/admin/accounts/users">Back to Users</a>
39
40
  </div>
40
41
  <div id="content" style="display: none;">
41
-
42
- <!-- User Information -->
43
- <div class="card">
44
- <h2>Information</h2>
45
- <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
46
- <label for="userName" style="min-width: 120px;"><strong>Name</strong></label>
47
- <input type="text" id="userName" style="max-width: 400px;">
48
- </div>
49
- <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
50
- <label for="userEmail" style="min-width: 120px;"><strong>Email</strong></label>
51
- <input type="email" id="userEmail" style="max-width: 400px;">
52
- </div>
53
- <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
54
- <label style="min-width: 120px;"><strong>Verified</strong></label>
55
- <k-toggle id="emailVerified"></k-toggle>
56
- </div>
57
- <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
58
- <label style="min-width: 120px;"><strong>Created</strong></label>
59
- <span id="userCreatedAt"></span>
60
- </div>
61
- </div>
62
-
63
- <!-- Groups -->
64
- <div class="card">
65
- <h2>Groups</h2>
66
- <k-table id="groupsTable" request-delete placeholder="Not a member of any groups.">
67
- <admin-table-row-link slot="before" url-pattern="/admin/accounts/groups/{name}" field-name="name"></admin-table-row-link>
68
- <kc-tc-delete-record slot="after"></kc-tc-delete-record>
69
- <div slot="top" id="groupsTableTop" class="d-f full" style="display:none; align-items: center;">
70
- <div><admin-group-delete-selected></admin-group-delete-selected></div>
71
- <div class="flex"></div>
72
- <div><admin-add-group-control id="addGroupBtn"></admin-add-group-control></div>
42
+ <k-tabs id="userTabs" active="information" hash-routing>
43
+ <k-tab for="information">Information</k-tab>
44
+ <k-tab for="groups">Groups</k-tab>
45
+ <k-tab for="sessions">Sessions</k-tab>
46
+
47
+ <k-tab-content name="information">
48
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
49
+ <label for="userName" style="min-width: 120px;"><strong>Name</strong></label>
50
+ <input type="text" id="userName" style="max-width: 400px;">
73
51
  </div>
74
- </k-table>
75
- </div>
76
-
77
- <!-- Sessions -->
78
- <div class="card">
79
- <h2>Sessions</h2>
80
- <k-table id="sessionsTable" enable-pages page-size="10" page-size-options="[10,25,50]" placeholder="No active sessions.">
81
- <kc-tc-delete-record slot="after"></kc-tc-delete-record>
82
- <div slot="top" class="d-f full" style="align-items: center;">
83
- <div><kc-tc-delete-selected></kc-tc-delete-selected></div>
84
- <div class="flex"></div>
85
- <div><admin-clear-expired-sessions id="clearExpiredBtn" style="display:none;"></admin-clear-expired-sessions></div>
52
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
53
+ <label for="userEmail" style="min-width: 120px;"><strong>Email</strong></label>
54
+ <input type="email" id="userEmail" style="max-width: 400px;">
86
55
  </div>
87
- <div class="d-f full" style="align-items: center;">
88
- <div>
89
- <kc-tc-first-page></kc-tc-first-page>
90
- <kc-tc-prev-page></kc-tc-prev-page>
91
- <kc-tc-next-page></kc-tc-next-page>
92
- <kc-tc-last-page></kc-tc-last-page>
93
- </div>
94
- <div class="flex"></div>
95
- <div><kc-tc-page-select></kc-tc-page-select></div>
96
- <div class="flex"></div>
97
- <div><kc-tc-page-size></kc-tc-page-size></div>
56
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
57
+ <label style="min-width: 120px;"><strong>Verified</strong></label>
58
+ <k-toggle id="emailVerified"></k-toggle>
98
59
  </div>
99
- </k-table>
100
- </div>
60
+ <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
61
+ <label style="min-width: 120px;"><strong>Created</strong></label>
62
+ <span id="userCreatedAt"></span>
63
+ </div>
64
+ </k-tab-content>
65
+
66
+ <k-tab-content name="groups">
67
+ <k-table id="groupsTable" request-delete placeholder="Not a member of any groups.">
68
+ <admin-table-row-link slot="before" url-pattern="/admin/accounts/groups/{name}" field-name="name"></admin-table-row-link>
69
+ <kc-tc-delete-record slot="after"></kc-tc-delete-record>
70
+ <div slot="top" id="groupsTableTop" class="d-f full" style="display:none; align-items: center;">
71
+ <div><admin-group-delete-selected></admin-group-delete-selected></div>
72
+ <div class="flex"></div>
73
+ <div><admin-add-group-control id="addGroupBtn"></admin-add-group-control></div>
74
+ </div>
75
+ </k-table>
76
+ </k-tab-content>
77
+
78
+ <k-tab-content name="sessions">
79
+ <k-table id="sessionsTable" enable-pages page-size="10" page-size-options="[10,25,50]" placeholder="No active sessions.">
80
+ <kc-tc-delete-record slot="after"></kc-tc-delete-record>
81
+ <div slot="top" class="d-f full" style="align-items: center;">
82
+ <div><kc-tc-delete-selected></kc-tc-delete-selected></div>
83
+ <div class="flex"></div>
84
+ <div><admin-clear-expired-sessions id="clearExpiredBtn" style="display:none;"></admin-clear-expired-sessions></div>
85
+ </div>
86
+ <div class="d-f full" style="align-items: center;">
87
+ <div>
88
+ <kc-tc-first-page></kc-tc-first-page>
89
+ <kc-tc-prev-page></kc-tc-prev-page>
90
+ <kc-tc-next-page></kc-tc-next-page>
91
+ <kc-tc-last-page></kc-tc-last-page>
92
+ </div>
93
+ <div class="flex"></div>
94
+ <div><kc-tc-page-select></kc-tc-page-select></div>
95
+ <div class="flex"></div>
96
+ <div><kc-tc-page-size></kc-tc-page-size></div>
97
+ </div>
98
+ </k-table>
99
+ </k-tab-content>
101
100
 
101
+ <location name="admin-user-tabs" />
102
+ </k-tabs>
102
103
  </div>
103
104
 
105
+ <location name="admin-user-scripts" />
106
+
104
107
  <script type="module">
105
108
  import {
106
109
  getSession, getUser, updateUser, deleteUsers,
@@ -347,8 +350,6 @@
347
350
  });
348
351
  });
349
352
 
350
- loadGroups();
351
-
352
353
  /*
353
354
  Sessions
354
355
  */
@@ -420,7 +421,23 @@
420
421
  });
421
422
  };
422
423
 
423
- loadSessions();
424
+ /*
425
+ Each tab loads its data the first time it is opened, so a visit that never leaves Information makes no queries.
426
+ */
427
+
428
+ const $userTabs = document.getElementById('userTabs');
429
+ const shownTabs = new Set();
430
+ const loaders = { groups: loadGroups, sessions: loadSessions };
431
+
432
+ $userTabs.addEventListener('tabfirstshown', ({ detail }) => {
433
+ shownTabs.add(detail.tab);
434
+ loaders[detail.tab]?.();
435
+ });
436
+
437
+ // The tab the address opened on was chosen before this listener existed, so it is loaded here.
438
+ await $userTabs.updateComplete;
439
+ const opening = $userTabs.active;
440
+ if(loaders[opening] && !shownTabs.has(opening)) loaders[opening]();
424
441
  }
425
442
  </script>
426
443
 
@@ -1,13 +1,13 @@
1
- import t from"/kempo-ui/components/ShadowComponent.js";import{html as e,nothing as i}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Spinner.js";import"/kempo-ui/components/Icon.js";import"/kempo-ui/components/Dialog.js";import"/kempo-ui/components/CodeEditor.js";import s from"/kempo-ui/components/Toast.js";import{listEmailTemplates as a,getEmailTemplate as o,saveEmailTemplate as r,resetEmailTemplate as n}from"/kempo/sdk.js";export default class l extends t{static properties={owner:{type:String},previewUrl:{type:String,attribute:"preview-url"},templates:{state:!0},error:{state:!0},editing:{state:!0},saving:{state:!0}};constructor(){super(),this.owner="",this.previewUrl="/kempo/api/email-templates/preview",this.templates=null,this.error="",this.editing=null,this.saving=!1}connectedCallback(){super.connectedCallback(),this.load()}load=async()=>{const[t,e]=await a(this.owner);t?this.error=t.msg:this.templates=e.templates};edit=async t=>{const[e,i]=await o(t);e?s.error(e.msg):(this.editing=i,await this.updateComplete,this.renderRoot.querySelector("#emailDialog").open())};confirmSave=t=>{t.keepDialogOpen=!0,this.save()};save=async()=>{this.saving=!0;const[t]=await r({template:this.editing.template,source:this.renderRoot.querySelector("#emailEditor").value});this.saving=!1,t?s.error(t.msg):(this.renderRoot.querySelector("#emailDialog").close(),s.success("Email template saved"),await this.load())};reset=async()=>{this.saving=!0;const[t]=await n(this.editing.template);this.saving=!1,t?s.error(t.msg):(this.renderRoot.querySelector("#emailDialog").close(),s.success("Email template reset to the default"),await this.load())};renderDialog(){const t=this.editing;return t?e`
1
+ import t from"/kempo-ui/components/ShadowComponent.js";import{html as e,nothing as s}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Spinner.js";import"/kempo-ui/components/Icon.js";import"/kempo-ui/components/Dialog.js";import"/kempo-ui/components/CodeEditor.js";import i from"/kempo-ui/components/Toast.js";import{listEmailTemplates as a,getEmailTemplate as o,saveEmailTemplate as r,resetEmailTemplate as n}from"/kempo/sdk.js";export default class l extends t{static properties={owner:{type:String},previewUrl:{type:String,attribute:"preview-url"},templates:{state:!0},error:{state:!0},editing:{state:!0},saving:{state:!0}};constructor(){super(),this.owner="",this.previewUrl="/kempo/api/email-templates/preview",this.templates=null,this.error="",this.editing=null,this.saving=!1}connectedCallback(){super.connectedCallback();const t=this.closest("k-tab-content");!t||t.active?this.load():this.closest("k-tabs")?.addEventListener("tabfirstshown",({detail:e})=>{e.tab===t.name&&this.load()})}load=async()=>{const[t,e]=await a(this.owner);t?this.error=t.msg:this.templates=e.templates};edit=async t=>{const[e,s]=await o(t);e?i.error(e.msg):(this.editing=s,await this.updateComplete,this.renderRoot.querySelector("#emailDialog").open())};confirmSave=t=>{t.keepDialogOpen=!0,this.save()};save=async()=>{this.saving=!0;const[t]=await r({template:this.editing.template,source:this.renderRoot.querySelector("#emailEditor").value});this.saving=!1,t?i.error(t.msg):(this.renderRoot.querySelector("#emailDialog").close(),i.success("Email template saved"),await this.load())};reset=async()=>{this.saving=!0;const[t]=await n(this.editing.template);this.saving=!1,t?i.error(t.msg):(this.renderRoot.querySelector("#emailDialog").close(),i.success("Email template reset to the default"),await this.load())};renderDialog(){const t=this.editing;return t?e`
2
2
  <k-dialog id="emailDialog" style="--width: 60rem;" confirm-text=${this.saving?"Saving…":"Save"} confirm-classes="primary ml" cancel-text="Cancel" .confirmAction=${this.confirmSave}>
3
3
  <h2 slot="title" class="h5 pyh px m0">${t.title||t.name}</h2>
4
4
  <div class="p">
5
5
  <p class="tc-muted mb">${t.template}. Saving keeps your copy in <strong>templates/emails</strong>, which is used from the next email and is not changed by updates. A template that does not render is not saved.</p>
6
6
  <k-code-editor id="emailEditor" class="r b mb" language="html" .value=${t.source} style="height: 55vh; min-height: 300px;"></k-code-editor>
7
- ${t.overridden?e`<button type="button" class="danger" ?disabled=${this.saving} @click=${this.reset}>Reset to default</button>`:i}
7
+ ${t.overridden?e`<button type="button" class="danger" ?disabled=${this.saving} @click=${this.reset}>Reset to default</button>`:s}
8
8
  </div>
9
9
  </k-dialog>
10
- `:i}render(){return this.error?e`<p class="tc-danger" role="alert">${this.error}</p>`:this.templates?this.templates.length?e`
10
+ `:s}render(){return this.error?e`<p class="tc-danger" role="alert">${this.error}</p>`:this.templates?this.templates.length?e`
11
11
  <table class="mb">
12
12
  <thead><tr><th>Email</th><th>Template</th><th>Status</th><th></th></tr></thead>
13
13
  <tbody>${this.templates.map(t=>e`<tr>
@@ -1 +1 @@
1
- {"version":3,"file":"src\\admin\\components\\EmailTemplates.js","names":["ShadowComponent","html","nothing","Toast","listEmailTemplates","getEmailTemplate","saveEmailTemplate","resetEmailTemplate","EmailTemplates","static","owner","type","String","previewUrl","attribute","templates","state","error","editing","saving","constructor","super","this","connectedCallback","load","async","data","msg","edit","found","template","updateComplete","renderRoot","querySelector","open","confirmSave","event","keepDialogOpen","save","source","value","close","success","reset","renderDialog","title","name","overridden","render","length","map","encodeURIComponent","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Icon.js';\nimport '/kempo-ui/components/Dialog.js';\nimport '/kempo-ui/components/CodeEditor.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport { listEmailTemplates, getEmailTemplate, saveEmailTemplate, resetEmailTemplate } from '/kempo/sdk.js';\n\n/*\n The email templates of one extension, to edit, served at /admin/components/EmailTemplates.js:\n\n <script type=\"module\" src=\"/admin/components/EmailTemplates.js\"></script>\n <k-email-templates owner=\"kempo-commerce\"></k-email-templates>\n\n Without `owner` it lists kempo's own templates (verify-email, reset-password). Each row opens a dialog with the template in\n a code editor. Saving writes the site's own copy to `templates/emails/<owner>/<name>.page.html`, which is used from the next\n email on and survives updates of the extension; **Reset to default** removes it. The packaged template is never changed.\n\n **Preview** opens the template as it would be sent, in one named tab that each preview replaces. By default that is kempo's\n preview, which draws it with no data; an extension that can do better (its own orders as the data) serves its own and gives\n its address as `preview-url`:\n\n <k-email-templates owner=\"kempo-commerce\" preview-url=\"/commerce/api/emails/preview\"></k-email-templates>\n\n The preview is the saved template, not what is being typed in the editor.\n An extension puts this on its settings page; it needs no code of its own.\n*/\nexport default class EmailTemplates extends ShadowComponent {\n static properties = {\n owner: { type: String },\n previewUrl: { type: String, attribute: 'preview-url' },\n templates: { state: true },\n error: { state: true },\n editing: { state: true },\n saving: { state: true },\n };\n\n constructor(){\n super();\n this.owner = '';\n this.previewUrl = '/kempo/api/email-templates/preview';\n this.templates = null;\n this.error = '';\n this.editing = null;\n this.saving = false;\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n this.load();\n }\n\n /*\n Actions\n */\n load = async () => {\n const [error, data] = await listEmailTemplates(this.owner);\n if(error){\n this.error = error.msg;\n return;\n }\n this.templates = data.templates;\n };\n\n edit = async template => {\n const [error, found] = await getEmailTemplate(template);\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.editing = found;\n await this.updateComplete;\n this.renderRoot.querySelector('#emailDialog').open();\n };\n\n /* The dialog's Save button: it stays open until the template is saved, so a template that does not render is not lost. */\n confirmSave = event => {\n event.keepDialogOpen = true;\n this.save();\n };\n\n save = async () => {\n this.saving = true;\n const [error] = await saveEmailTemplate({ template: this.editing.template, source: this.renderRoot.querySelector('#emailEditor').value });\n this.saving = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#emailDialog').close();\n Toast.success('Email template saved');\n await this.load();\n };\n\n reset = async () => {\n this.saving = true;\n const [error] = await resetEmailTemplate(this.editing.template);\n this.saving = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#emailDialog').close();\n Toast.success('Email template reset to the default');\n await this.load();\n };\n\n /*\n Rendering\n */\n renderDialog(){\n const template = this.editing;\n if(!template) return nothing;\n return html`\n <k-dialog id=\"emailDialog\" style=\"--width: 60rem;\" confirm-text=${this.saving ? 'Saving…' : 'Save'} confirm-classes=\"primary ml\" cancel-text=\"Cancel\" .confirmAction=${this.confirmSave}>\n <h2 slot=\"title\" class=\"h5 pyh px m0\">${template.title || template.name}</h2>\n <div class=\"p\">\n <p class=\"tc-muted mb\">${template.template}. Saving keeps your copy in <strong>templates/emails</strong>, which is used from the next email and is not changed by updates. A template that does not render is not saved.</p>\n <k-code-editor id=\"emailEditor\" class=\"r b mb\" language=\"html\" .value=${template.source} style=\"height: 55vh; min-height: 300px;\"></k-code-editor>\n ${template.overridden ? html`<button type=\"button\" class=\"danger\" ?disabled=${this.saving} @click=${this.reset}>Reset to default</button>` : nothing}\n </div>\n </k-dialog>\n `;\n }\n\n render(){\n if(this.error) return html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>`;\n if(!this.templates) return html`<k-spinner></k-spinner>`;\n if(!this.templates.length) return html`<p class=\"tc-muted\">There are no email templates here.</p>`;\n return html`\n <table class=\"mb\">\n <thead><tr><th>Email</th><th>Template</th><th>Status</th><th></th></tr></thead>\n <tbody>${this.templates.map(template => html`<tr>\n <td>${template.title || template.name}</td>\n <td class=\"tc-muted\">${template.template}</td>\n <td>${template.overridden ? html`<span class=\"tc-warning\">Customized</span>` : html`<span class=\"tc-muted\">Default</span>`}</td>\n <td class=\"ta-right\">\n <a class=\"btn\" href=${`${this.previewUrl}?template=${encodeURIComponent(template.template)}`} target=\"email-preview\">Preview</a>\n <button aria-label=\"Edit\" title=\"Edit\" @click=${() => this.edit(template.template)}><k-icon name=\"edit\"></k-icon></button>\n </td>\n </tr>`)}</tbody>\n </table>\n ${this.renderDialog()}\n `;\n }\n}\n\ncustomElements.define('k-email-templates', EmailTemplates);\n"],"mappings":"OAAOA,MAAqB,yDACnBC,aAAMC,MAAe,iCACvB,wCACA,qCACA,uCACA,4CACAC,MAAW,6DACTC,sBAAoBC,uBAAkBC,wBAAmBC,MAA0B,+BAqB7E,MAAMC,UAAuBR,EAC1CS,kBAAoB,CAClBC,MAAO,CAAEC,KAAMC,QACfC,WAAY,CAAEF,KAAMC,OAAQE,UAAW,eACvCC,UAAW,CAAEC,OAAO,GACpBC,MAAO,CAAED,OAAO,GAChBE,QAAS,CAAEF,OAAO,GAClBG,OAAQ,CAAEH,OAAO,IAGnB,WAAAI,GACEC,QACAC,KAAKZ,MAAQ,GACbY,KAAKT,WAAa,qCAClBS,KAAKP,UAAY,KACjBO,KAAKL,MAAQ,GACbK,KAAKJ,QAAU,KACfI,KAAKH,QAAS,CAChB,CAKA,iBAAAI,GACEF,MAAME,oBACND,KAAKE,MACP,CAKAA,KAAOC,UACL,MAAOR,EAAOS,SAActB,EAAmBkB,KAAKZ,OACjDO,EACDK,KAAKL,MAAQA,EAAMU,IAGrBL,KAAKP,UAAYW,EAAKX,WAGxBa,KAAOH,UACL,MAAOR,EAAOY,SAAexB,EAAiByB,GAC3Cb,EACDd,EAAMc,MAAMA,EAAMU,MAGpBL,KAAKJ,QAAUW,QACTP,KAAKS,eACXT,KAAKU,WAAWC,cAAc,gBAAgBC,SAIhDC,YAAcC,IACZA,EAAMC,gBAAiB,EACvBf,KAAKgB,QAGPA,KAAOb,UACLH,KAAKH,QAAS,EACd,MAAOF,SAAeX,EAAkB,CAAEwB,SAAUR,KAAKJ,QAAQY,SAAUS,OAAQjB,KAAKU,WAAWC,cAAc,gBAAgBO,QACjIlB,KAAKH,QAAS,EACXF,EACDd,EAAMc,MAAMA,EAAMU,MAGpBL,KAAKU,WAAWC,cAAc,gBAAgBQ,QAC9CtC,EAAMuC,QAAQ,8BACRpB,KAAKE,SAGbmB,MAAQlB,UACNH,KAAKH,QAAS,EACd,MAAOF,SAAeV,EAAmBe,KAAKJ,QAAQY,UACtDR,KAAKH,QAAS,EACXF,EACDd,EAAMc,MAAMA,EAAMU,MAGpBL,KAAKU,WAAWC,cAAc,gBAAgBQ,QAC9CtC,EAAMuC,QAAQ,6CACRpB,KAAKE,SAMb,YAAAoB,GACE,MAAMd,EAAWR,KAAKJ,QACtB,OAAIY,EACG7B,CAAI;wEACyDqB,KAAKH,OAAS,UAAY,2EAA2EG,KAAKa;gDAClIL,EAASe,OAASf,EAASgB;;mCAExChB,EAASA;kFACsCA,EAASS;YAC/ET,EAASiB,WAAa9C,CAAI,kDAAkDqB,KAAKH,iBAAiBG,KAAKqB,kCAAoCzC;;;MAP9HA,CAWvB,CAEA,MAAA8C,GACE,OAAG1B,KAAKL,MAAchB,CAAI,qCAAqCqB,KAAKL,YAChEK,KAAKP,UACLO,KAAKP,UAAUkC,OACZhD,CAAI;;;iBAGEqB,KAAKP,UAAUmC,IAAIpB,GAAY7B,CAAI;gBACpC6B,EAASe,OAASf,EAASgB;iCACVhB,EAASA;gBAC1BA,EAASiB,WAAa9C,CAAI,6CAA+CA,CAAI;;kCAE3D,GAAGqB,KAAKT,uBAAuBsC,mBAAmBrB,EAASA;4DACjC,IAAMR,KAAKM,KAAKE,EAASA;;;;QAI7ER,KAAKsB;MAdyB3C,CAAI,6DADXA,CAAI,yBAiBjC,EAGFmD,eAAeC,OAAO,oBAAqB7C","ignoreList":[]}
1
+ {"version":3,"file":"src\\admin\\components\\EmailTemplates.js","names":["ShadowComponent","html","nothing","Toast","listEmailTemplates","getEmailTemplate","saveEmailTemplate","resetEmailTemplate","EmailTemplates","static","owner","type","String","previewUrl","attribute","templates","state","error","editing","saving","constructor","super","this","connectedCallback","pane","closest","active","load","addEventListener","detail","tab","name","async","data","msg","edit","found","template","updateComplete","renderRoot","querySelector","open","confirmSave","event","keepDialogOpen","save","source","value","close","success","reset","renderDialog","title","overridden","render","length","map","encodeURIComponent","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Icon.js';\nimport '/kempo-ui/components/Dialog.js';\nimport '/kempo-ui/components/CodeEditor.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport { listEmailTemplates, getEmailTemplate, saveEmailTemplate, resetEmailTemplate } from '/kempo/sdk.js';\n\n/*\n The email templates of one extension, to edit, served at /admin/components/EmailTemplates.js:\n\n <script type=\"module\" src=\"/admin/components/EmailTemplates.js\"></script>\n <k-email-templates owner=\"kempo-commerce\"></k-email-templates>\n\n Without `owner` it lists kempo's own templates (verify-email, reset-password). Each row opens a dialog with the template in\n a code editor. Saving writes the site's own copy to `templates/emails/<owner>/<name>.page.html`, which is used from the next\n email on and survives updates of the extension; **Reset to default** removes it. The packaged template is never changed.\n\n **Preview** opens the template as it would be sent, in one named tab that each preview replaces. By default that is kempo's\n preview, which draws it with no data; an extension that can do better (its own orders as the data) serves its own and gives\n its address as `preview-url`:\n\n <k-email-templates owner=\"kempo-commerce\" preview-url=\"/commerce/api/emails/preview\"></k-email-templates>\n\n The preview is the saved template, not what is being typed in the editor.\n An extension puts this on its settings page; it needs no code of its own.\n*/\nexport default class EmailTemplates extends ShadowComponent {\n static properties = {\n owner: { type: String },\n previewUrl: { type: String, attribute: 'preview-url' },\n templates: { state: true },\n error: { state: true },\n editing: { state: true },\n saving: { state: true },\n };\n\n constructor(){\n super();\n this.owner = '';\n this.previewUrl = '/kempo/api/email-templates/preview';\n this.templates = null;\n this.error = '';\n this.editing = null;\n this.saving = false;\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\nconst pane = this.closest('k-tab-content');\n // Loads when its tab is first opened, so a tab nobody opens makes no requests.\n if(!pane || pane.active) this.load();\n else this.closest('k-tabs')?.addEventListener('tabfirstshown', ({ detail }) => {\n if(detail.tab === pane.name) this.load();\n });\n }\n\n /*\n Actions\n */\n load = async () => {\n const [error, data] = await listEmailTemplates(this.owner);\n if(error){\n this.error = error.msg;\n return;\n }\n this.templates = data.templates;\n };\n\n edit = async template => {\n const [error, found] = await getEmailTemplate(template);\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.editing = found;\n await this.updateComplete;\n this.renderRoot.querySelector('#emailDialog').open();\n };\n\n /* The dialog's Save button: it stays open until the template is saved, so a template that does not render is not lost. */\n confirmSave = event => {\n event.keepDialogOpen = true;\n this.save();\n };\n\n save = async () => {\n this.saving = true;\n const [error] = await saveEmailTemplate({ template: this.editing.template, source: this.renderRoot.querySelector('#emailEditor').value });\n this.saving = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#emailDialog').close();\n Toast.success('Email template saved');\n await this.load();\n };\n\n reset = async () => {\n this.saving = true;\n const [error] = await resetEmailTemplate(this.editing.template);\n this.saving = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#emailDialog').close();\n Toast.success('Email template reset to the default');\n await this.load();\n };\n\n /*\n Rendering\n */\n renderDialog(){\n const template = this.editing;\n if(!template) return nothing;\n return html`\n <k-dialog id=\"emailDialog\" style=\"--width: 60rem;\" confirm-text=${this.saving ? 'Saving…' : 'Save'} confirm-classes=\"primary ml\" cancel-text=\"Cancel\" .confirmAction=${this.confirmSave}>\n <h2 slot=\"title\" class=\"h5 pyh px m0\">${template.title || template.name}</h2>\n <div class=\"p\">\n <p class=\"tc-muted mb\">${template.template}. Saving keeps your copy in <strong>templates/emails</strong>, which is used from the next email and is not changed by updates. A template that does not render is not saved.</p>\n <k-code-editor id=\"emailEditor\" class=\"r b mb\" language=\"html\" .value=${template.source} style=\"height: 55vh; min-height: 300px;\"></k-code-editor>\n ${template.overridden ? html`<button type=\"button\" class=\"danger\" ?disabled=${this.saving} @click=${this.reset}>Reset to default</button>` : nothing}\n </div>\n </k-dialog>\n `;\n }\n\n render(){\n if(this.error) return html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>`;\n if(!this.templates) return html`<k-spinner></k-spinner>`;\n if(!this.templates.length) return html`<p class=\"tc-muted\">There are no email templates here.</p>`;\n return html`\n <table class=\"mb\">\n <thead><tr><th>Email</th><th>Template</th><th>Status</th><th></th></tr></thead>\n <tbody>${this.templates.map(template => html`<tr>\n <td>${template.title || template.name}</td>\n <td class=\"tc-muted\">${template.template}</td>\n <td>${template.overridden ? html`<span class=\"tc-warning\">Customized</span>` : html`<span class=\"tc-muted\">Default</span>`}</td>\n <td class=\"ta-right\">\n <a class=\"btn\" href=${`${this.previewUrl}?template=${encodeURIComponent(template.template)}`} target=\"email-preview\">Preview</a>\n <button aria-label=\"Edit\" title=\"Edit\" @click=${() => this.edit(template.template)}><k-icon name=\"edit\"></k-icon></button>\n </td>\n </tr>`)}</tbody>\n </table>\n ${this.renderDialog()}\n `;\n }\n}\n\ncustomElements.define('k-email-templates', EmailTemplates);\n"],"mappings":"OAAOA,MAAqB,yDACnBC,aAAMC,MAAe,iCACvB,wCACA,qCACA,uCACA,4CACAC,MAAW,6DACTC,sBAAoBC,uBAAkBC,wBAAmBC,MAA0B,+BAqB7E,MAAMC,UAAuBR,EAC1CS,kBAAoB,CAClBC,MAAO,CAAEC,KAAMC,QACfC,WAAY,CAAEF,KAAMC,OAAQE,UAAW,eACvCC,UAAW,CAAEC,OAAO,GACpBC,MAAO,CAAED,OAAO,GAChBE,QAAS,CAAEF,OAAO,GAClBG,OAAQ,CAAEH,OAAO,IAGnB,WAAAI,GACEC,QACAC,KAAKZ,MAAQ,GACbY,KAAKT,WAAa,qCAClBS,KAAKP,UAAY,KACjBO,KAAKL,MAAQ,GACbK,KAAKJ,QAAU,KACfI,KAAKH,QAAS,CAChB,CAKA,iBAAAI,GACEF,MAAME,oBACV,MAAMC,EAAOF,KAAKG,QAAQ,kBAElBD,GAAQA,EAAKE,OAAQJ,KAAKK,OACzBL,KAAKG,QAAQ,WAAWG,iBAAiB,gBAAiB,EAAGC,aAC7DA,EAAOC,MAAQN,EAAKO,MAAMT,KAAKK,QAEtC,CAKAA,KAAOK,UACL,MAAOf,EAAOgB,SAAc7B,EAAmBkB,KAAKZ,OACjDO,EACDK,KAAKL,MAAQA,EAAMiB,IAGrBZ,KAAKP,UAAYkB,EAAKlB,WAGxBoB,KAAOH,UACL,MAAOf,EAAOmB,SAAe/B,EAAiBgC,GAC3CpB,EACDd,EAAMc,MAAMA,EAAMiB,MAGpBZ,KAAKJ,QAAUkB,QACTd,KAAKgB,eACXhB,KAAKiB,WAAWC,cAAc,gBAAgBC,SAIhDC,YAAcC,IACZA,EAAMC,gBAAiB,EACvBtB,KAAKuB,QAGPA,KAAOb,UACLV,KAAKH,QAAS,EACd,MAAOF,SAAeX,EAAkB,CAAE+B,SAAUf,KAAKJ,QAAQmB,SAAUS,OAAQxB,KAAKiB,WAAWC,cAAc,gBAAgBO,QACjIzB,KAAKH,QAAS,EACXF,EACDd,EAAMc,MAAMA,EAAMiB,MAGpBZ,KAAKiB,WAAWC,cAAc,gBAAgBQ,QAC9C7C,EAAM8C,QAAQ,8BACR3B,KAAKK,SAGbuB,MAAQlB,UACNV,KAAKH,QAAS,EACd,MAAOF,SAAeV,EAAmBe,KAAKJ,QAAQmB,UACtDf,KAAKH,QAAS,EACXF,EACDd,EAAMc,MAAMA,EAAMiB,MAGpBZ,KAAKiB,WAAWC,cAAc,gBAAgBQ,QAC9C7C,EAAM8C,QAAQ,6CACR3B,KAAKK,SAMb,YAAAwB,GACE,MAAMd,EAAWf,KAAKJ,QACtB,OAAImB,EACGpC,CAAI;wEACyDqB,KAAKH,OAAS,UAAY,2EAA2EG,KAAKoB;gDAClIL,EAASe,OAASf,EAASN;;mCAExCM,EAASA;kFACsCA,EAASS;YAC/ET,EAASgB,WAAapD,CAAI,kDAAkDqB,KAAKH,iBAAiBG,KAAK4B,kCAAoChD;;;MAP9HA,CAWvB,CAEA,MAAAoD,GACE,OAAGhC,KAAKL,MAAchB,CAAI,qCAAqCqB,KAAKL,YAChEK,KAAKP,UACLO,KAAKP,UAAUwC,OACZtD,CAAI;;;iBAGEqB,KAAKP,UAAUyC,IAAInB,GAAYpC,CAAI;gBACpCoC,EAASe,OAASf,EAASN;iCACVM,EAASA;gBAC1BA,EAASgB,WAAapD,CAAI,6CAA+CA,CAAI;;kCAE3D,GAAGqB,KAAKT,uBAAuB4C,mBAAmBpB,EAASA;4DACjC,IAAMf,KAAKa,KAAKE,EAASA;;;;QAI7Ef,KAAK6B;MAdyBlD,CAAI,6DADXA,CAAI,yBAiBjC,EAGFyD,eAAeC,OAAO,oBAAqBnD","ignoreList":[]}
@@ -1,4 +1,4 @@
1
- import t from"/kempo-ui/components/ShadowComponent.js";import{html as s,nothing as e}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Spinner.js";import"/kempo-ui/components/Dialog.js";import a from"/kempo-ui/components/Toast.js";import{listEmails as i,getEmail as o,retryEmail as r,sendTestEmail as l,getAllCurrentUserPermissions as n}from"/kempo/sdk.js";const c={sent:"tc-success",failed:"tc-danger",sending:"tc-warning",queued:"tc-muted"},h=t=>t?new Date(t).toLocaleString():"";export default class d extends t{static properties={emails:{state:!0},total:{state:!0},offset:{state:!0},status:{state:!0},search:{state:!0},loading:{state:!0},error:{state:!0},canManage:{state:!0},open:{state:!0},testTo:{state:!0},busy:{state:!0}};constructor(){super(),this.emails=[],this.total=0,this.offset=0,this.status="",this.search="",this.loading=!0,this.error="",this.canManage=!1,this.open=null,this.testTo="",this.busy=!1}connectedCallback(){super.connectedCallback(),n().then(([,t])=>{this.canManage=(t?.permissions??[]).includes("system:emails:manage")}),this.load()}load=async()=>{this.loading=!0;const[t,s]=await i({status:this.status||void 0,search:this.search||void 0,limit:25,offset:this.offset});this.loading=!1,t?this.error=t.msg||"The emails could not be loaded":(this.error="",this.emails=s.emails,this.total=s.total)};filter=async t=>{Object.assign(this,t,{offset:0}),await this.load()};page=async t=>{this.offset=Math.max(0,this.offset+25*t),await this.load()};show=async t=>{const[s,e]=await o(t);s?a.error(s.msg):(this.open=e,await this.updateComplete,this.renderRoot.querySelector("#emailDialog").open())};retry=async()=>{this.busy=!0;const[t,s]=await r(this.open.id);this.busy=!1,t?a.error(t.msg):s.sent?a.success("The email was sent"):a.error(s.error||"The email could not be sent"),this.renderRoot.querySelector("#emailDialog").close(),await this.load()};openTest=()=>this.renderRoot.querySelector("#testDialog").open();confirmTest=t=>{t.keepDialogOpen=!0,this.sendTest()};sendTest=async()=>{this.busy=!0;const[t,s]=await l(this.testTo.trim());this.busy=!1,t?a.error(t.msg):(this.renderRoot.querySelector("#testDialog").close(),s.sent?a.success(`A test email was sent to ${this.testTo.trim()}`):a.error(s.error||"The test email could not be sent"),await this.load())};renderDetail(){const t=this.open;return t?s`
1
+ import t from"/kempo-ui/components/ShadowComponent.js";import{html as s,nothing as e}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Spinner.js";import"/kempo-ui/components/Dialog.js";import a from"/kempo-ui/components/Toast.js";import{listEmails as i,getEmail as o,retryEmail as r,sendTestEmail as l,getAllCurrentUserPermissions as n}from"/kempo/sdk.js";const c={sent:"tc-success",failed:"tc-danger",sending:"tc-warning",queued:"tc-muted"},h=t=>t?new Date(t).toLocaleString():"";export default class d extends t{static properties={emails:{state:!0},total:{state:!0},offset:{state:!0},status:{state:!0},search:{state:!0},loading:{state:!0},error:{state:!0},canManage:{state:!0},open:{state:!0},testTo:{state:!0},busy:{state:!0}};constructor(){super(),this.emails=[],this.total=0,this.offset=0,this.status="",this.search="",this.loading=!0,this.error="",this.canManage=!1,this.open=null,this.testTo="",this.busy=!1}connectedCallback(){super.connectedCallback(),n().then(([,t])=>{this.canManage=(t?.permissions??[]).includes("system:emails:manage")});const t=this.closest("k-tab-content");!t||t.active?this.load():this.closest("k-tabs")?.addEventListener("tabfirstshown",({detail:s})=>{s.tab===t.name&&this.load()})}load=async()=>{this.loading=!0;const[t,s]=await i({status:this.status||void 0,search:this.search||void 0,limit:25,offset:this.offset});this.loading=!1,t?this.error=t.msg||"The emails could not be loaded":(this.error="",this.emails=s.emails,this.total=s.total)};filter=async t=>{Object.assign(this,t,{offset:0}),await this.load()};page=async t=>{this.offset=Math.max(0,this.offset+25*t),await this.load()};show=async t=>{const[s,e]=await o(t);s?a.error(s.msg):(this.open=e,await this.updateComplete,this.renderRoot.querySelector("#emailDialog").open())};retry=async()=>{this.busy=!0;const[t,s]=await r(this.open.id);this.busy=!1,t?a.error(t.msg):s.sent?a.success("The email was sent"):a.error(s.error||"The email could not be sent"),this.renderRoot.querySelector("#emailDialog").close(),await this.load()};openTest=()=>this.renderRoot.querySelector("#testDialog").open();confirmTest=t=>{t.keepDialogOpen=!0,this.sendTest()};sendTest=async()=>{this.busy=!0;const[t,s]=await l(this.testTo.trim());this.busy=!1,t?a.error(t.msg):(this.renderRoot.querySelector("#testDialog").close(),s.sent?a.success(`A test email was sent to ${this.testTo.trim()}`):a.error(s.error||"The test email could not be sent"),await this.load())};renderDetail(){const t=this.open;return t?s`
2
2
  <k-dialog id="emailDialog" style="--width: 60rem;" cancel-text="Close">
3
3
  <h2 slot="title" class="h5 pyh px m0">${t.subject}</h2>
4
4
  <div class="p">
@@ -1 +1 @@
1
- {"version":3,"file":"src\\admin\\components\\Emails.js","names":["ShadowComponent","html","nothing","Toast","listEmails","getEmail","retryEmail","sendTestEmail","getAllCurrentUserPermissions","STATUS_CLASS","sent","failed","sending","queued","when","value","Date","toLocaleString","Emails","static","emails","state","total","offset","status","search","loading","error","canManage","open","testTo","busy","constructor","super","this","connectedCallback","then","data","permissions","includes","load","async","undefined","limit","msg","filter","Object","assign","changes","page","Math","max","step","show","found","id","updateComplete","renderRoot","querySelector","retry","result","success","close","openTest","confirmTest","event","keepDialogOpen","sendTest","trim","renderDetail","mail","subject","attempts","provider","recipient","sender","type","createdAt","sentAt","lastError","renderTest","target","render","last","min","map","length","preventDefault","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Dialog.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport { listEmails, getEmail, retryEmail, sendTestEmail, getAllCurrentUserPermissions } from '/kempo/sdk.js';\n\n/*\n The log of emails, served at /admin/components/Emails.js:\n\n <k-admin-emails></k-admin-emails>\n\n Every email kempo was asked to send is here, newest first, with how it went: `queued` (waiting for a sender or a retry), `sending`,\n `sent` or `failed`. Click one to read it, with what it said and the reason it did not go. Someone who can manage emails can try a\n waiting or failed one again and send a test email, which is the quickest way to see that an email extension is set up.\n*/\nconst PAGE_SIZE = 25;\nconst STATUS_CLASS = { sent: 'tc-success', failed: 'tc-danger', sending: 'tc-warning', queued: 'tc-muted' };\n\nconst when = value => (value ? new Date(value).toLocaleString() : '');\n\nexport default class Emails extends ShadowComponent {\n static properties = {\n emails: { state: true },\n total: { state: true },\n offset: { state: true },\n status: { state: true },\n search: { state: true },\n loading: { state: true },\n error: { state: true },\n canManage: { state: true },\n open: { state: true },\n testTo: { state: true },\n busy: { state: true },\n };\n\n constructor(){\n super();\n this.emails = [];\n this.total = 0;\n this.offset = 0;\n this.status = '';\n this.search = '';\n this.loading = true;\n this.error = '';\n this.canManage = false;\n this.open = null;\n this.testTo = '';\n this.busy = false;\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n getAllCurrentUserPermissions().then(([, data]) => { this.canManage = (data?.permissions ?? []).includes('system:emails:manage'); });\n this.load();\n }\n\n /*\n Loading\n */\n load = async () => {\n this.loading = true;\n const [error, data] = await listEmails({ status: this.status || undefined, search: this.search || undefined, limit: PAGE_SIZE, offset: this.offset });\n this.loading = false;\n if(error){\n this.error = error.msg || 'The emails could not be loaded';\n return;\n }\n this.error = '';\n this.emails = data.emails;\n this.total = data.total;\n };\n\n filter = async changes => {\n Object.assign(this, changes, { offset: 0 });\n await this.load();\n };\n\n page = async step => {\n this.offset = Math.max(0, this.offset + step * PAGE_SIZE);\n await this.load();\n };\n\n /*\n One email\n */\n show = async id => {\n const [error, found] = await getEmail(id);\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.open = found;\n await this.updateComplete;\n this.renderRoot.querySelector('#emailDialog').open();\n };\n\n retry = async () => {\n this.busy = true;\n const [error, result] = await retryEmail(this.open.id);\n this.busy = false;\n if(error) Toast.error(error.msg);\n else if(result.sent) Toast.success('The email was sent');\n else Toast.error(result.error || 'The email could not be sent');\n this.renderRoot.querySelector('#emailDialog').close();\n await this.load();\n };\n\n /*\n Test email\n */\n openTest = () => this.renderRoot.querySelector('#testDialog').open();\n\n confirmTest = event => {\n event.keepDialogOpen = true;\n this.sendTest();\n };\n\n sendTest = async () => {\n this.busy = true;\n const [error, result] = await sendTestEmail(this.testTo.trim());\n this.busy = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#testDialog').close();\n if(result.sent) Toast.success(`A test email was sent to ${this.testTo.trim()}`);\n else Toast.error(result.error || 'The test email could not be sent');\n await this.load();\n };\n\n /*\n Rendering\n */\n renderDetail(){\n const mail = this.open;\n if(!mail) return nothing;\n return html`\n <k-dialog id=\"emailDialog\" style=\"--width: 60rem;\" cancel-text=\"Close\">\n <h2 slot=\"title\" class=\"h5 pyh px m0\">${mail.subject}</h2>\n <div class=\"p\">\n <p class=\"mb\"><strong class=${STATUS_CLASS[mail.status] ?? ''}>${mail.status}</strong>${mail.attempts ? html` · ${mail.attempts} ${mail.attempts === 1 ? 'try' : 'tries'}` : nothing}${mail.provider ? html` · sent by ${mail.provider}` : nothing}</p>\n <p class=\"mb\">To <strong>${mail.recipient}</strong>, from ${mail.sender}<br><span class=\"tc-muted\">${mail.type} · created ${when(mail.createdAt)}${mail.sentAt ? ` · sent ${when(mail.sentAt)}` : ''}</span></p>\n ${mail.lastError ? html`<p class=\"tc-danger mb\" role=\"alert\">${mail.lastError}</p>` : nothing}\n ${this.canManage && ['queued', 'failed'].includes(mail.status) ? html`<p class=\"mb\"><button class=\"primary\" ?disabled=${this.busy} @click=${this.retry}>${this.busy ? 'Trying…' : 'Try again'}</button></p>` : nothing}\n <iframe title=\"The email\" sandbox=\"\" .srcdoc=${mail.html} class=\"full b r\" height=\"420\"></iframe>\n </div>\n </k-dialog>\n `;\n }\n\n renderTest(){\n return html`\n <k-dialog id=\"testDialog\" confirm-text=${this.busy ? 'Sending…' : 'Send test email'} confirm-classes=\"primary ml\" cancel-text=\"Cancel\" .confirmAction=${this.confirmTest}>\n <h2 slot=\"title\" class=\"h5 pyh px m0\">Send a test email</h2>\n <div class=\"p\">\n <label class=\"mb d-b\">Send it to<input type=\"email\" .value=${this.testTo} @input=${event => { this.testTo = event.target.value; }}></label>\n <p class=\"tc-muted\">It goes out through the email extension that is installed, and shows in the log below.</p>\n </div>\n </k-dialog>\n `;\n }\n\n render(){\n const last = Math.min(this.offset + PAGE_SIZE, this.total);\n return html`\n <div class=\"d-f mb\">\n <label class=\"mr\">Status\n <select @change=${event => this.filter({ status: event.target.value })}>\n ${['', 'queued', 'sending', 'sent', 'failed'].map(status => html`<option value=${status} ?selected=${this.status === status}>${status || 'All'}</option>`)}\n </select>\n </label>\n <label class=\"mr\">Search\n <input type=\"search\" placeholder=\"Address or subject\" .value=${this.search} @change=${event => this.filter({ search: event.target.value })}>\n </label>\n ${this.canManage ? html`<button class=\"mb\" @click=${this.openTest}>Send a test email</button>` : nothing}\n </div>\n ${this.error ? html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>` : nothing}\n ${this.loading ? html`<k-spinner></k-spinner>` : this.emails.length ? html`\n <table class=\"mb\">\n <thead><tr><th>Status</th><th>To</th><th>Subject</th><th>Type</th><th>Created</th></tr></thead>\n <tbody>${this.emails.map(mail => html`<tr>\n <td><strong class=${STATUS_CLASS[mail.status] ?? ''}>${mail.status}</strong></td>\n <td>${mail.recipient}</td>\n <td><a href=\"#\" @click=${event => { event.preventDefault(); this.show(mail.id); }}>${mail.subject}</a>${mail.lastError ? html`<br><span class=\"tc-danger\">${mail.lastError}</span>` : nothing}</td>\n <td class=\"tc-muted\">${mail.type}</td>\n <td class=\"tc-muted\">${when(mail.createdAt)}</td>\n </tr>`)}</tbody>\n </table>\n <p class=\"d-f mb\"><button ?disabled=${this.offset === 0} @click=${() => this.page(-1)}>Newer</button><span class=\"tc-muted ph\">${this.offset + 1}–${last} of ${this.total}</span><button ?disabled=${last >= this.total} @click=${() => this.page(1)}>Older</button></p>\n ` : html`<p class=\"tc-muted\">No emails have been sent yet.</p>`}\n ${this.renderDetail()}\n ${this.renderTest()}\n `;\n }\n}\n\ncustomElements.define('k-admin-emails', Emails);\n"],"mappings":"OAAOA,MAAqB,yDACnBC,aAAMC,MAAe,iCACvB,wCACA,wCACAC,MAAW,qDACTC,cAAYC,gBAAUC,mBAAYC,kCAAeC,MAAoC,gBAW9F,MACMC,EAAe,CAAEC,KAAM,aAAcC,OAAQ,YAAaC,QAAS,aAAcC,OAAQ,YAEzFC,EAAOC,GAAUA,EAAQ,IAAIC,KAAKD,GAAOE,iBAAmB,kBAEnD,MAAMC,UAAelB,EAClCmB,kBAAoB,CAClBC,OAAQ,CAAEC,OAAO,GACjBC,MAAO,CAAED,OAAO,GAChBE,OAAQ,CAAEF,OAAO,GACjBG,OAAQ,CAAEH,OAAO,GACjBI,OAAQ,CAAEJ,OAAO,GACjBK,QAAS,CAAEL,OAAO,GAClBM,MAAO,CAAEN,OAAO,GAChBO,UAAW,CAAEP,OAAO,GACpBQ,KAAM,CAAER,OAAO,GACfS,OAAQ,CAAET,OAAO,GACjBU,KAAM,CAAEV,OAAO,IAGjB,WAAAW,GACEC,QACAC,KAAKd,OAAS,GACdc,KAAKZ,MAAQ,EACbY,KAAKX,OAAS,EACdW,KAAKV,OAAS,GACdU,KAAKT,OAAS,GACdS,KAAKR,SAAU,EACfQ,KAAKP,MAAQ,GACbO,KAAKN,WAAY,EACjBM,KAAKL,KAAO,KACZK,KAAKJ,OAAS,GACdI,KAAKH,MAAO,CACd,CAKA,iBAAAI,GACEF,MAAME,oBACN3B,IAA+B4B,KAAK,EAAE,CAAEC,MAAYH,KAAKN,WAAaS,GAAMC,aAAe,IAAIC,SAAS,0BACxGL,KAAKM,MACP,CAKAA,KAAOC,UACLP,KAAKR,SAAU,EACf,MAAOC,EAAOU,SAAcjC,EAAW,CAAEoB,OAAQU,KAAKV,aAAUkB,EAAWjB,OAAQS,KAAKT,aAAUiB,EAAWC,MAjD/F,GAiDiHpB,OAAQW,KAAKX,SAC5IW,KAAKR,SAAU,EACZC,EACDO,KAAKP,MAAQA,EAAMiB,KAAO,kCAG5BV,KAAKP,MAAQ,GACbO,KAAKd,OAASiB,EAAKjB,OACnBc,KAAKZ,MAAQe,EAAKf,QAGpBuB,OAASJ,UACPK,OAAOC,OAAOb,KAAMc,EAAS,CAAEzB,OAAQ,UACjCW,KAAKM,QAGbS,KAAOR,UACLP,KAAKX,OAAS2B,KAAKC,IAAI,EAAGjB,KAAKX,OAlEjB,GAkE0B6B,SAClClB,KAAKM,QAMba,KAAOZ,UACL,MAAOd,EAAO2B,SAAejD,EAASkD,GACnC5B,EACDxB,EAAMwB,MAAMA,EAAMiB,MAGpBV,KAAKL,KAAOyB,QACNpB,KAAKsB,eACXtB,KAAKuB,WAAWC,cAAc,gBAAgB7B,SAGhD8B,MAAQlB,UACNP,KAAKH,MAAO,EACZ,MAAOJ,EAAOiC,SAAgBtD,EAAW4B,KAAKL,KAAK0B,IACnDrB,KAAKH,MAAO,EACTJ,EAAOxB,EAAMwB,MAAMA,EAAMiB,KACpBgB,EAAOlD,KAAMP,EAAM0D,QAAQ,sBAC9B1D,EAAMwB,MAAMiC,EAAOjC,OAAS,+BACjCO,KAAKuB,WAAWC,cAAc,gBAAgBI,cACxC5B,KAAKM,QAMbuB,SAAW,IAAM7B,KAAKuB,WAAWC,cAAc,eAAe7B,OAE9DmC,YAAcC,IACZA,EAAMC,gBAAiB,EACvBhC,KAAKiC,YAGPA,SAAW1B,UACTP,KAAKH,MAAO,EACZ,MAAOJ,EAAOiC,SAAgBrD,EAAc2B,KAAKJ,OAAOsC,QACxDlC,KAAKH,MAAO,EACTJ,EACDxB,EAAMwB,MAAMA,EAAMiB,MAGpBV,KAAKuB,WAAWC,cAAc,eAAeI,QAC1CF,EAAOlD,KAAMP,EAAM0D,QAAQ,4BAA4B3B,KAAKJ,OAAOsC,UACjEjE,EAAMwB,MAAMiC,EAAOjC,OAAS,0CAC3BO,KAAKM,SAMb,YAAA6B,GACE,MAAMC,EAAOpC,KAAKL,KAClB,OAAIyC,EACGrE,CAAI;;gDAEiCqE,EAAKC;;wCAEb9D,EAAa6D,EAAK9C,SAAW,MAAM8C,EAAK9C,kBAAkB8C,EAAKE,SAAWvE,CAAI,MAAMqE,EAAKE,YAA8B,IAAlBF,EAAKE,SAAiB,MAAQ,UAAYtE,IAAUoE,EAAKG,SAAWxE,CAAI,cAAcqE,EAAKG,WAAavE;qCAChNoE,EAAKI,4BAA4BJ,EAAKK,oCAAoCL,EAAKM,kBAAkB9D,EAAKwD,EAAKO,aAAaP,EAAKQ,OAAS,WAAWhE,EAAKwD,EAAKQ,UAAY;YAChMR,EAAKS,UAAY9E,CAAI,wCAAwCqE,EAAKS,gBAAkB7E;YACpFgC,KAAKN,WAAa,CAAC,SAAU,UAAUW,SAAS+B,EAAK9C,QAAUvB,CAAI,mDAAmDiC,KAAKH,eAAeG,KAAKyB,SAASzB,KAAKH,KAAO,UAAY,2BAA6B7B;yDAChKoE,EAAKrE;;;MATzCC,CAanB,CAEA,UAAA8E,GACE,OAAO/E,CAAI;+CACgCiC,KAAKH,KAAO,WAAa,sFAAsFG,KAAK8B;;;uEAG5F9B,KAAKJ,iBAAiBmC,IAAW/B,KAAKJ,OAASmC,EAAMgB,OAAOlE;;;;KAKjI,CAEA,MAAAmE,GACE,MAAMC,EAAOjC,KAAKkC,IAAIlD,KAAKX,OAxJb,GAwJiCW,KAAKZ,OACpD,OAAOrB,CAAI;;;4BAGagE,GAAS/B,KAAKW,OAAO,CAAErB,OAAQyC,EAAMgB,OAAOlE;cAC1D,CAAC,GAAI,SAAU,UAAW,OAAQ,UAAUsE,IAAI7D,GAAUvB,CAAI,iBAAiBuB,eAAoBU,KAAKV,SAAWA,KAAUA,GAAU;;;;yEAI5EU,KAAKT,kBAAkBwC,GAAS/B,KAAKW,OAAO,CAAEpB,OAAQwC,EAAMgB,OAAOlE;;UAElImB,KAAKN,UAAY3B,CAAI,6BAA6BiC,KAAK6B,sCAAwC7D;;QAEjGgC,KAAKP,MAAQ1B,CAAI,qCAAqCiC,KAAKP,YAAczB;QACzEgC,KAAKR,QAAUzB,CAAI,0BAA4BiC,KAAKd,OAAOkE,OAASrF,CAAI;;;mBAG7DiC,KAAKd,OAAOiE,IAAIf,GAAQrE,CAAI;gCACfQ,EAAa6D,EAAK9C,SAAW,MAAM8C,EAAK9C;kBACtD8C,EAAKI;qCACcT,IAAWA,EAAMsB,iBAAkBrD,KAAKmB,KAAKiB,EAAKf,QAAUe,EAAKC,cAAcD,EAAKS,UAAY9E,CAAI,+BAA+BqE,EAAKS,mBAAqB7E;mCAC/JoE,EAAKM;mCACL9D,EAAKwD,EAAKO;;;8CAGiB,IAAhB3C,KAAKX,iBAAuB,IAAMW,KAAKe,MAAM,8CAA8Cf,KAAKX,OAAS,KAAK4D,QAAWjD,KAAKZ,iCAAiC6D,GAAQjD,KAAKZ,gBAAgB,IAAMY,KAAKe,KAAK;QAChPhD,CAAI;QACNiC,KAAKmC;QACLnC,KAAK8C;KAEX,EAGFQ,eAAeC,OAAO,iBAAkBvE","ignoreList":[]}
1
+ {"version":3,"file":"src\\admin\\components\\Emails.js","names":["ShadowComponent","html","nothing","Toast","listEmails","getEmail","retryEmail","sendTestEmail","getAllCurrentUserPermissions","STATUS_CLASS","sent","failed","sending","queued","when","value","Date","toLocaleString","Emails","static","emails","state","total","offset","status","search","loading","error","canManage","open","testTo","busy","constructor","super","this","connectedCallback","then","data","permissions","includes","pane","closest","active","load","addEventListener","detail","tab","name","async","undefined","limit","msg","filter","Object","assign","changes","page","Math","max","step","show","found","id","updateComplete","renderRoot","querySelector","retry","result","success","close","openTest","confirmTest","event","keepDialogOpen","sendTest","trim","renderDetail","mail","subject","attempts","provider","recipient","sender","type","createdAt","sentAt","lastError","renderTest","target","render","last","min","map","length","preventDefault","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Dialog.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport { listEmails, getEmail, retryEmail, sendTestEmail, getAllCurrentUserPermissions } from '/kempo/sdk.js';\n\n/*\n The log of emails, served at /admin/components/Emails.js:\n\n <k-admin-emails></k-admin-emails>\n\n Every email kempo was asked to send is here, newest first, with how it went: `queued` (waiting for a sender or a retry), `sending`,\n `sent` or `failed`. Click one to read it, with what it said and the reason it did not go. Someone who can manage emails can try a\n waiting or failed one again and send a test email, which is the quickest way to see that an email extension is set up.\n*/\nconst PAGE_SIZE = 25;\nconst STATUS_CLASS = { sent: 'tc-success', failed: 'tc-danger', sending: 'tc-warning', queued: 'tc-muted' };\n\nconst when = value => (value ? new Date(value).toLocaleString() : '');\n\nexport default class Emails extends ShadowComponent {\n static properties = {\n emails: { state: true },\n total: { state: true },\n offset: { state: true },\n status: { state: true },\n search: { state: true },\n loading: { state: true },\n error: { state: true },\n canManage: { state: true },\n open: { state: true },\n testTo: { state: true },\n busy: { state: true },\n };\n\n constructor(){\n super();\n this.emails = [];\n this.total = 0;\n this.offset = 0;\n this.status = '';\n this.search = '';\n this.loading = true;\n this.error = '';\n this.canManage = false;\n this.open = null;\n this.testTo = '';\n this.busy = false;\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n getAllCurrentUserPermissions().then(([, data]) => { this.canManage = (data?.permissions ?? []).includes('system:emails:manage'); });\nconst pane = this.closest('k-tab-content');\n // Loads when its tab is first opened, so a tab nobody opens makes no requests.\n if(!pane || pane.active) this.load();\n else this.closest('k-tabs')?.addEventListener('tabfirstshown', ({ detail }) => {\n if(detail.tab === pane.name) this.load();\n });\n }\n\n /*\n Loading\n */\n load = async () => {\n this.loading = true;\n const [error, data] = await listEmails({ status: this.status || undefined, search: this.search || undefined, limit: PAGE_SIZE, offset: this.offset });\n this.loading = false;\n if(error){\n this.error = error.msg || 'The emails could not be loaded';\n return;\n }\n this.error = '';\n this.emails = data.emails;\n this.total = data.total;\n };\n\n filter = async changes => {\n Object.assign(this, changes, { offset: 0 });\n await this.load();\n };\n\n page = async step => {\n this.offset = Math.max(0, this.offset + step * PAGE_SIZE);\n await this.load();\n };\n\n /*\n One email\n */\n show = async id => {\n const [error, found] = await getEmail(id);\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.open = found;\n await this.updateComplete;\n this.renderRoot.querySelector('#emailDialog').open();\n };\n\n retry = async () => {\n this.busy = true;\n const [error, result] = await retryEmail(this.open.id);\n this.busy = false;\n if(error) Toast.error(error.msg);\n else if(result.sent) Toast.success('The email was sent');\n else Toast.error(result.error || 'The email could not be sent');\n this.renderRoot.querySelector('#emailDialog').close();\n await this.load();\n };\n\n /*\n Test email\n */\n openTest = () => this.renderRoot.querySelector('#testDialog').open();\n\n confirmTest = event => {\n event.keepDialogOpen = true;\n this.sendTest();\n };\n\n sendTest = async () => {\n this.busy = true;\n const [error, result] = await sendTestEmail(this.testTo.trim());\n this.busy = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#testDialog').close();\n if(result.sent) Toast.success(`A test email was sent to ${this.testTo.trim()}`);\n else Toast.error(result.error || 'The test email could not be sent');\n await this.load();\n };\n\n /*\n Rendering\n */\n renderDetail(){\n const mail = this.open;\n if(!mail) return nothing;\n return html`\n <k-dialog id=\"emailDialog\" style=\"--width: 60rem;\" cancel-text=\"Close\">\n <h2 slot=\"title\" class=\"h5 pyh px m0\">${mail.subject}</h2>\n <div class=\"p\">\n <p class=\"mb\"><strong class=${STATUS_CLASS[mail.status] ?? ''}>${mail.status}</strong>${mail.attempts ? html` · ${mail.attempts} ${mail.attempts === 1 ? 'try' : 'tries'}` : nothing}${mail.provider ? html` · sent by ${mail.provider}` : nothing}</p>\n <p class=\"mb\">To <strong>${mail.recipient}</strong>, from ${mail.sender}<br><span class=\"tc-muted\">${mail.type} · created ${when(mail.createdAt)}${mail.sentAt ? ` · sent ${when(mail.sentAt)}` : ''}</span></p>\n ${mail.lastError ? html`<p class=\"tc-danger mb\" role=\"alert\">${mail.lastError}</p>` : nothing}\n ${this.canManage && ['queued', 'failed'].includes(mail.status) ? html`<p class=\"mb\"><button class=\"primary\" ?disabled=${this.busy} @click=${this.retry}>${this.busy ? 'Trying…' : 'Try again'}</button></p>` : nothing}\n <iframe title=\"The email\" sandbox=\"\" .srcdoc=${mail.html} class=\"full b r\" height=\"420\"></iframe>\n </div>\n </k-dialog>\n `;\n }\n\n renderTest(){\n return html`\n <k-dialog id=\"testDialog\" confirm-text=${this.busy ? 'Sending…' : 'Send test email'} confirm-classes=\"primary ml\" cancel-text=\"Cancel\" .confirmAction=${this.confirmTest}>\n <h2 slot=\"title\" class=\"h5 pyh px m0\">Send a test email</h2>\n <div class=\"p\">\n <label class=\"mb d-b\">Send it to<input type=\"email\" .value=${this.testTo} @input=${event => { this.testTo = event.target.value; }}></label>\n <p class=\"tc-muted\">It goes out through the email extension that is installed, and shows in the log below.</p>\n </div>\n </k-dialog>\n `;\n }\n\n render(){\n const last = Math.min(this.offset + PAGE_SIZE, this.total);\n return html`\n <div class=\"d-f mb\">\n <label class=\"mr\">Status\n <select @change=${event => this.filter({ status: event.target.value })}>\n ${['', 'queued', 'sending', 'sent', 'failed'].map(status => html`<option value=${status} ?selected=${this.status === status}>${status || 'All'}</option>`)}\n </select>\n </label>\n <label class=\"mr\">Search\n <input type=\"search\" placeholder=\"Address or subject\" .value=${this.search} @change=${event => this.filter({ search: event.target.value })}>\n </label>\n ${this.canManage ? html`<button class=\"mb\" @click=${this.openTest}>Send a test email</button>` : nothing}\n </div>\n ${this.error ? html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>` : nothing}\n ${this.loading ? html`<k-spinner></k-spinner>` : this.emails.length ? html`\n <table class=\"mb\">\n <thead><tr><th>Status</th><th>To</th><th>Subject</th><th>Type</th><th>Created</th></tr></thead>\n <tbody>${this.emails.map(mail => html`<tr>\n <td><strong class=${STATUS_CLASS[mail.status] ?? ''}>${mail.status}</strong></td>\n <td>${mail.recipient}</td>\n <td><a href=\"#\" @click=${event => { event.preventDefault(); this.show(mail.id); }}>${mail.subject}</a>${mail.lastError ? html`<br><span class=\"tc-danger\">${mail.lastError}</span>` : nothing}</td>\n <td class=\"tc-muted\">${mail.type}</td>\n <td class=\"tc-muted\">${when(mail.createdAt)}</td>\n </tr>`)}</tbody>\n </table>\n <p class=\"d-f mb\"><button ?disabled=${this.offset === 0} @click=${() => this.page(-1)}>Newer</button><span class=\"tc-muted ph\">${this.offset + 1}–${last} of ${this.total}</span><button ?disabled=${last >= this.total} @click=${() => this.page(1)}>Older</button></p>\n ` : html`<p class=\"tc-muted\">No emails have been sent yet.</p>`}\n ${this.renderDetail()}\n ${this.renderTest()}\n `;\n }\n}\n\ncustomElements.define('k-admin-emails', Emails);\n"],"mappings":"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","ignoreList":[]}
@@ -1,4 +1,4 @@
1
- import e from"/kempo-ui/components/ShadowComponent.js";import{html as t,nothing as s}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Spinner.js";import"/kempo-ui/components/Icon.js";import i from"/kempo-ui/components/Toast.js";import n from"/kempo-ui/components/Dialog.js";import{listSettings as a,setSetting as r,deleteSetting as o,getAllCurrentUserPermissions as l}from"/kempo/sdk.js";import"./Setting.js";import c from"./humanize.js";const u={system:"system:settings:update",custom:"system:custom-settings:manage"};let h=null;const d={string:"",number:0,boolean:!1,json:{},secret:""},m=e=>{if(null===e.value||void 0===e.value||"secret"===e.type)return e;try{if("number"===e.type)return{...e,value:Number(e.value)};if("boolean"===e.type)return{...e,value:!0===e.value||"true"===e.value};if("json"===e.type)return{...e,value:"string"==typeof e.value?JSON.parse(e.value):e.value}}catch{return e}return e};export default class p extends e{static properties={owner:{type:String},heading:{type:String},settings:{state:!0},changes:{state:!0},canEdit:{state:!0},loading:{state:!0},busy:{state:!0},error:{state:!0}};constructor(){super(),this.owner="",this.heading="",this.settings=[],this.changes={},this.canEdit=!1,this.loading=!0,this.busy=!1,this.error=""}connectedCallback(){super.connectedCallback(),this.load()}shortName=e=>e.name.slice(this.owner.length+1);load=async()=>{const[[e,t],s]=await Promise.all([a({owner:this.owner,limit:1e3}),h??=l().then(([,e])=>new Set(e?.permissions??[]))]);this.canEdit=s.has(u[this.owner]??"system:extensions:manage"),this.loading=!1,e?this.error=e.msg||"The settings could not be loaded":(this.error="",this.settings=t.settings.map(m),this.changes={})};record=e=>{const{name:t,value:s,valid:i}=e.detail,n=this.settings.find(e=>this.shortName(e)===t),a=n&&("secret"===n.type?""===s:JSON.stringify(s)===JSON.stringify(n.value)),{[t]:r,...o}=this.changes;this.changes=a&&i?o:{...o,[t]:{value:s,valid:i}}};get dirty(){return Object.keys(this.changes).length>0}get blocked(){return Object.values(this.changes).some(e=>!e.valid)}save=async()=>{this.busy=!0;let e=0;for(const[t,s]of Object.entries(this.changes)){const n=this.settings.find(e=>this.shortName(e)===t),[a]=await r(this.owner,t,{value:s.value,type:n.type,isPublic:n.isPublic,description:n.description});a&&(e++,i.error(`${c(t)}: ${a.msg||"could not be saved"}`))}this.busy=!1,e||i.success("Settings saved"),await this.load()};create=()=>{const e=n.create(t`
1
+ import e from"/kempo-ui/components/ShadowComponent.js";import{html as t,nothing as s}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Spinner.js";import"/kempo-ui/components/Icon.js";import i from"/kempo-ui/components/Toast.js";import n from"/kempo-ui/components/Dialog.js";import{listSettings as a,setSetting as r,deleteSetting as o,getAllCurrentUserPermissions as l}from"/kempo/sdk.js";import"./Setting.js";import c from"./humanize.js";const u={system:"system:settings:update",custom:"system:custom-settings:manage"};let h=null;const d={string:"",number:0,boolean:!1,json:{},secret:""},m=e=>{if(null===e.value||void 0===e.value||"secret"===e.type)return e;try{if("number"===e.type)return{...e,value:Number(e.value)};if("boolean"===e.type)return{...e,value:!0===e.value||"true"===e.value};if("json"===e.type)return{...e,value:"string"==typeof e.value?JSON.parse(e.value):e.value}}catch{return e}return e};export default class p extends e{static properties={owner:{type:String},heading:{type:String},settings:{state:!0},changes:{state:!0},canEdit:{state:!0},loading:{state:!0},busy:{state:!0},error:{state:!0}};constructor(){super(),this.owner="",this.heading="",this.settings=[],this.changes={},this.canEdit=!1,this.loading=!0,this.busy=!1,this.error=""}connectedCallback(){super.connectedCallback();const e=this.closest("k-tab-content");!e||e.active?this.load():this.closest("k-tabs")?.addEventListener("tabfirstshown",({detail:t})=>{t.tab===e.name&&this.load()})}shortName=e=>e.name.slice(this.owner.length+1);load=async()=>{const[[e,t],s]=await Promise.all([a({owner:this.owner,limit:1e3}),h??=l().then(([,e])=>new Set(e?.permissions??[]))]);this.canEdit=s.has(u[this.owner]??"system:extensions:manage"),this.loading=!1,e?this.error=e.msg||"The settings could not be loaded":(this.error="",this.settings=t.settings.map(m),this.changes={})};record=e=>{const{name:t,value:s,valid:i}=e.detail,n=this.settings.find(e=>this.shortName(e)===t),a=n&&("secret"===n.type?""===s:JSON.stringify(s)===JSON.stringify(n.value)),{[t]:r,...o}=this.changes;this.changes=a&&i?o:{...o,[t]:{value:s,valid:i}}};get dirty(){return Object.keys(this.changes).length>0}get blocked(){return Object.values(this.changes).some(e=>!e.valid)}save=async()=>{this.busy=!0;let e=0;for(const[t,s]of Object.entries(this.changes)){const n=this.settings.find(e=>this.shortName(e)===t),[a]=await r(this.owner,t,{value:s.value,type:n.type,isPublic:n.isPublic,description:n.description});a&&(e++,i.error(`${c(t)}: ${a.msg||"could not be saved"}`))}this.busy=!1,e||i.success("Settings saved"),await this.load()};create=()=>{const e=n.create(t`
2
2
  <div class="p">
3
3
  <label class="mb d-b">Name<input type="text" id="newName" placeholder="my_setting" required></label>
4
4
  <label class="mb d-b">Type
@@ -1 +1 @@
1
- {"version":3,"file":"src\\admin\\components\\settings\\SettingsPanel.js","names":["ShadowComponent","html","nothing","Toast","Dialog","listSettings","setSetting","deleteSetting","getAllCurrentUserPermissions","humanize","PERMISSIONS","system","custom","permissionsPromise","DEFAULTS","string","number","boolean","json","secret","typed","record","value","undefined","type","Number","JSON","parse","SettingsPanel","static","owner","String","heading","settings","state","changes","canEdit","loading","busy","error","constructor","super","this","connectedCallback","load","shortName","name","slice","length","async","data","permissions","Promise","all","limit","then","Set","has","msg","map","event","valid","detail","find","entry","unchanged","stringify","removed","rest","dirty","Object","keys","blocked","values","some","change","save","failed","entries","isPublic","description","success","create","dialog","title","confirmText","cancelText","confirmAction","read","id","querySelector","trim","includes","text","isFinite","Error","toLowerCase","coerce","checked","addEventListener","target","remove","confirm","confirmed","confirmClasses","cancelClasses","renderSetting","hasOwn","editor","render","firstUpdated","renderRoot","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Icon.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport Dialog from '/kempo-ui/components/Dialog.js';\nimport { listSettings, setSetting, deleteSetting, getAllCurrentUserPermissions } from '/kempo/sdk.js';\nimport './Setting.js';\nimport humanize from './humanize.js';\n\n/*\n The settings of one owner, each with the editor for its type, and a Save button for what was changed. Served at\n /admin/components/settings/SettingsPanel.js:\n\n <k-settings-panel owner=\"system\"></k-settings-panel>\n <k-settings-panel owner=\"my-extension\" heading=\"My Extension\"></k-settings-panel>\n\n `owner` is `system`, `custom` or the name of an extension. The Custom panel also creates and deletes settings. An extension drops\n one of these in its own settings tab and needs no editor of its own. Whether the person can change them depends on who owns\n them (`system:settings:update`, `system:custom-settings:manage`, `system:extensions:manage`); without it the fields are read-only.\n Saving sends only what was changed, each as its own setting.\n*/\nconst PERMISSIONS = { system: 'system:settings:update', custom: 'system:custom-settings:manage' };\nconst EXTENSION_PERMISSION = 'system:extensions:manage';\n\nlet permissionsPromise = null;\nconst myPermissions = () => (permissionsPromise ??= getAllCurrentUserPermissions().then(([, data]) => new Set(data?.permissions ?? [])));\n\nconst DEFAULTS = { string: '', number: 0, boolean: false, json: {}, secret: '' };\n\nconst coerce = (text, type) => {\n if(type === 'number'){\n const number = Number(text);\n if(String(text).trim() === '' || !Number.isFinite(number)) throw new Error('number');\n return number;\n }\n if(type === 'boolean') return String(text).toLowerCase() === 'true';\n if(type === 'json') return JSON.parse(String(text || '{}'));\n return String(text);\n};\n\n/*\n The settings API gives each value as it is stored, as text (`'true'`, `'7'`, a JSON string). The editors take real values, so the value\n is turned into the type the setting has. A secret is left as the mask the API sends: it only says that one is saved.\n*/\nconst typed = record => {\n if(record.value === null || record.value === undefined || record.type === 'secret') return record;\n try {\n if(record.type === 'number') return { ...record, value: Number(record.value) };\n if(record.type === 'boolean') return { ...record, value: record.value === true || record.value === 'true' };\n if(record.type === 'json') return { ...record, value: typeof record.value === 'string' ? JSON.parse(record.value) : record.value };\n } catch {\n return record;\n }\n return record;\n};\n\nexport default class SettingsPanel extends ShadowComponent {\n static properties = {\n owner: { type: String },\n heading: { type: String },\n settings: { state: true },\n changes: { state: true },\n canEdit: { state: true },\n loading: { state: true },\n busy: { state: true },\n error: { state: true },\n };\n\n constructor(){\n super();\n this.owner = '';\n this.heading = '';\n this.settings = [];\n this.changes = {};\n this.canEdit = false;\n this.loading = true;\n this.busy = false;\n this.error = '';\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n this.load();\n }\n\n /*\n Loading and saving\n */\n shortName = record => record.name.slice(this.owner.length + 1);\n\n load = async () => {\n const [[error, data], permissions] = await Promise.all([listSettings({ owner: this.owner, limit: 1000 }), myPermissions()]);\n this.canEdit = permissions.has(PERMISSIONS[this.owner] ?? EXTENSION_PERMISSION);\n this.loading = false;\n if(error){\n this.error = error.msg || 'The settings could not be loaded';\n return;\n }\n this.error = '';\n this.settings = data.settings.map(typed);\n this.changes = {};\n };\n\n record = event => {\n const { name, value, valid } = event.detail;\n const record = this.settings.find(entry => this.shortName(entry) === name);\n const unchanged = record && (record.type === 'secret' ? value === '' : JSON.stringify(value) === JSON.stringify(record.value));\n const { [name]: removed, ...rest } = this.changes;\n this.changes = unchanged && valid ? rest : { ...rest, [name]: { value, valid } };\n };\n\n get dirty(){\n return Object.keys(this.changes).length > 0;\n }\n\n get blocked(){\n return Object.values(this.changes).some(change => !change.valid);\n }\n\n save = async () => {\n this.busy = true;\n let failed = 0;\n for(const [name, change] of Object.entries(this.changes)){\n const record = this.settings.find(entry => this.shortName(entry) === name);\n const [error] = await setSetting(this.owner, name, { value: change.value, type: record.type, isPublic: record.isPublic, description: record.description });\n if(error){\n failed++;\n Toast.error(`${humanize(name)}: ${error.msg || 'could not be saved'}`);\n }\n }\n this.busy = false;\n if(!failed) Toast.success('Settings saved');\n await this.load();\n };\n\n /*\n Custom settings: create and delete\n */\n create = () => {\n const dialog = Dialog.create(html`\n <div class=\"p\">\n <label class=\"mb d-b\">Name<input type=\"text\" id=\"newName\" placeholder=\"my_setting\" required></label>\n <label class=\"mb d-b\">Type\n <select id=\"newType\">\n <option value=\"string\">String</option>\n <option value=\"number\">Number</option>\n <option value=\"boolean\">Boolean (true or false)</option>\n <option value=\"json\">JSON</option>\n <option value=\"secret\">Secret</option>\n </select>\n </label>\n <label class=\"mb d-b\">Value<input type=\"text\" id=\"newValue\"></label>\n <label class=\"mb d-b\">Description<input type=\"text\" id=\"newDescription\"></label>\n <label class=\"mb d-b\"><input type=\"checkbox\" id=\"newPublic\"> Public (anyone can read it, without signing in)</label>\n </div>\n `, {\n title: 'Create a setting',\n confirmText: 'Create',\n cancelText: 'Cancel',\n confirmAction: async () => {\n const read = id => dialog.querySelector(id);\n const name = read('#newName').value.trim();\n const type = read('#newType').value;\n if(!name){ Toast.error('Give the setting a name'); return; }\n if(name.includes(':')){ Toast.error('The name cannot contain \":\"'); return; }\n let value;\n try {\n value = read('#newValue').value === '' ? DEFAULTS[type] : coerce(read('#newValue').value, type);\n } catch {\n Toast.error(`That is not a valid ${type} value`);\n return;\n }\n const [error] = await setSetting('custom', name, { value, type, isPublic: type !== 'secret' && read('#newPublic').checked, description: read('#newDescription').value.trim() });\n if(error){ Toast.error(error.msg || 'The setting could not be created'); return; }\n Toast.success('Setting created');\n await this.load();\n },\n });\n dialog.querySelector('#newType').addEventListener('change', event => {\n dialog.querySelector('#newValue').type = event.target.value === 'secret' ? 'password' : 'text';\n });\n };\n\n remove = record => {\n Dialog.confirm(`Delete ${humanize(this.shortName(record))}? This cannot be undone.`, async confirmed => {\n if(!confirmed) return;\n const [error] = await deleteSetting(this.owner, this.shortName(record));\n if(error){ Toast.error(error.msg || 'The setting could not be deleted'); return; }\n Toast.success('Setting deleted');\n await this.load();\n }, { title: 'Delete setting', confirmText: 'Delete', confirmClasses: 'danger ml', cancelText: 'Cancel', cancelClasses: '' });\n };\n\n /*\n Rendering\n */\n renderSetting(record){\n const name = this.shortName(record);\n const value = Object.hasOwn(this.changes, name) && this.changes[name].valid ? this.changes[name].value : record.value;\n const editor = html`<k-setting .setting=${{ name, value, type: record.type, description: record.description }} ?disabled=${!this.canEdit}></k-setting>`;\n if(this.owner !== 'custom' || !this.canEdit) return editor;\n return html`<div class=\"d-f\"><div class=\"flex\">${editor}</div><div><button class=\"danger\" aria-label=${`Delete ${humanize(name)}`} title=\"Delete\" @click=${() => this.remove(record)}><k-icon name=\"delete\"></k-icon></button></div></div>`;\n }\n\n render(){\n if(this.loading) return html`<k-spinner></k-spinner>`;\n if(this.error) return html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>`;\n return html`\n ${this.heading ? html`<h2 class=\"h4\">${this.heading}</h2>` : nothing}\n ${this.owner === 'custom' && this.canEdit ? html`<p class=\"mb\"><button @click=${this.create}><k-icon name=\"add\"></k-icon> Create a setting</button></p>` : nothing}\n ${this.canEdit ? nothing : html`<p class=\"tc-muted mb\">You can see these settings but not change them.</p>`}\n ${this.settings.length ? this.settings.map(record => this.renderSetting(record)) : html`<p class=\"tc-muted mb\">${this.owner === 'custom' ? 'There are no custom settings yet.' : 'There are no settings here.'}</p>`}\n ${this.canEdit && this.settings.length ? html`<button class=\"primary\" ?disabled=${!this.dirty || this.blocked || this.busy} @click=${this.save}>${this.busy ? 'Saving…' : 'Save settings'}</button>` : nothing}\n `;\n }\n\n firstUpdated(){\n this.renderRoot.addEventListener('setting-change', this.record);\n }\n}\n\ncustomElements.define('k-settings-panel', SettingsPanel);\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"file":"src\\admin\\components\\settings\\SettingsPanel.js","names":["ShadowComponent","html","nothing","Toast","Dialog","listSettings","setSetting","deleteSetting","getAllCurrentUserPermissions","humanize","PERMISSIONS","system","custom","permissionsPromise","DEFAULTS","string","number","boolean","json","secret","typed","record","value","undefined","type","Number","JSON","parse","SettingsPanel","static","owner","String","heading","settings","state","changes","canEdit","loading","busy","error","constructor","super","this","connectedCallback","pane","closest","active","load","addEventListener","detail","tab","name","shortName","slice","length","async","data","permissions","Promise","all","limit","then","Set","has","msg","map","event","valid","find","entry","unchanged","stringify","removed","rest","dirty","Object","keys","blocked","values","some","change","save","failed","entries","isPublic","description","success","create","dialog","title","confirmText","cancelText","confirmAction","read","id","querySelector","trim","includes","text","isFinite","Error","toLowerCase","coerce","checked","target","remove","confirm","confirmed","confirmClasses","cancelClasses","renderSetting","hasOwn","editor","render","firstUpdated","renderRoot","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Icon.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport Dialog from '/kempo-ui/components/Dialog.js';\nimport { listSettings, setSetting, deleteSetting, getAllCurrentUserPermissions } from '/kempo/sdk.js';\nimport './Setting.js';\nimport humanize from './humanize.js';\n\n/*\n The settings of one owner, each with the editor for its type, and a Save button for what was changed. Served at\n /admin/components/settings/SettingsPanel.js:\n\n <k-settings-panel owner=\"system\"></k-settings-panel>\n <k-settings-panel owner=\"my-extension\" heading=\"My Extension\"></k-settings-panel>\n\n `owner` is `system`, `custom` or the name of an extension. The Custom panel also creates and deletes settings. An extension drops\n one of these in its own settings tab and needs no editor of its own. Whether the person can change them depends on who owns\n them (`system:settings:update`, `system:custom-settings:manage`, `system:extensions:manage`); without it the fields are read-only.\n Saving sends only what was changed, each as its own setting.\n*/\nconst PERMISSIONS = { system: 'system:settings:update', custom: 'system:custom-settings:manage' };\nconst EXTENSION_PERMISSION = 'system:extensions:manage';\n\nlet permissionsPromise = null;\nconst myPermissions = () => (permissionsPromise ??= getAllCurrentUserPermissions().then(([, data]) => new Set(data?.permissions ?? [])));\n\nconst DEFAULTS = { string: '', number: 0, boolean: false, json: {}, secret: '' };\n\nconst coerce = (text, type) => {\n if(type === 'number'){\n const number = Number(text);\n if(String(text).trim() === '' || !Number.isFinite(number)) throw new Error('number');\n return number;\n }\n if(type === 'boolean') return String(text).toLowerCase() === 'true';\n if(type === 'json') return JSON.parse(String(text || '{}'));\n return String(text);\n};\n\n/*\n The settings API gives each value as it is stored, as text (`'true'`, `'7'`, a JSON string). The editors take real values, so the value\n is turned into the type the setting has. A secret is left as the mask the API sends: it only says that one is saved.\n*/\nconst typed = record => {\n if(record.value === null || record.value === undefined || record.type === 'secret') return record;\n try {\n if(record.type === 'number') return { ...record, value: Number(record.value) };\n if(record.type === 'boolean') return { ...record, value: record.value === true || record.value === 'true' };\n if(record.type === 'json') return { ...record, value: typeof record.value === 'string' ? JSON.parse(record.value) : record.value };\n } catch {\n return record;\n }\n return record;\n};\n\nexport default class SettingsPanel extends ShadowComponent {\n static properties = {\n owner: { type: String },\n heading: { type: String },\n settings: { state: true },\n changes: { state: true },\n canEdit: { state: true },\n loading: { state: true },\n busy: { state: true },\n error: { state: true },\n };\n\n constructor(){\n super();\n this.owner = '';\n this.heading = '';\n this.settings = [];\n this.changes = {};\n this.canEdit = false;\n this.loading = true;\n this.busy = false;\n this.error = '';\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n const pane = this.closest('k-tab-content');\n // Loads when its tab is first opened, so a tab nobody opens makes no requests.\n if(!pane || pane.active) this.load();\n else this.closest('k-tabs')?.addEventListener('tabfirstshown', ({ detail }) => {\n if(detail.tab === pane.name) this.load();\n });\n }\n\n /*\n Loading and saving\n */\n shortName = record => record.name.slice(this.owner.length + 1);\n\n load = async () => {\n const [[error, data], permissions] = await Promise.all([listSettings({ owner: this.owner, limit: 1000 }), myPermissions()]);\n this.canEdit = permissions.has(PERMISSIONS[this.owner] ?? EXTENSION_PERMISSION);\n this.loading = false;\n if(error){\n this.error = error.msg || 'The settings could not be loaded';\n return;\n }\n this.error = '';\n this.settings = data.settings.map(typed);\n this.changes = {};\n };\n\n record = event => {\n const { name, value, valid } = event.detail;\n const record = this.settings.find(entry => this.shortName(entry) === name);\n const unchanged = record && (record.type === 'secret' ? value === '' : JSON.stringify(value) === JSON.stringify(record.value));\n const { [name]: removed, ...rest } = this.changes;\n this.changes = unchanged && valid ? rest : { ...rest, [name]: { value, valid } };\n };\n\n get dirty(){\n return Object.keys(this.changes).length > 0;\n }\n\n get blocked(){\n return Object.values(this.changes).some(change => !change.valid);\n }\n\n save = async () => {\n this.busy = true;\n let failed = 0;\n for(const [name, change] of Object.entries(this.changes)){\n const record = this.settings.find(entry => this.shortName(entry) === name);\n const [error] = await setSetting(this.owner, name, { value: change.value, type: record.type, isPublic: record.isPublic, description: record.description });\n if(error){\n failed++;\n Toast.error(`${humanize(name)}: ${error.msg || 'could not be saved'}`);\n }\n }\n this.busy = false;\n if(!failed) Toast.success('Settings saved');\n await this.load();\n };\n\n /*\n Custom settings: create and delete\n */\n create = () => {\n const dialog = Dialog.create(html`\n <div class=\"p\">\n <label class=\"mb d-b\">Name<input type=\"text\" id=\"newName\" placeholder=\"my_setting\" required></label>\n <label class=\"mb d-b\">Type\n <select id=\"newType\">\n <option value=\"string\">String</option>\n <option value=\"number\">Number</option>\n <option value=\"boolean\">Boolean (true or false)</option>\n <option value=\"json\">JSON</option>\n <option value=\"secret\">Secret</option>\n </select>\n </label>\n <label class=\"mb d-b\">Value<input type=\"text\" id=\"newValue\"></label>\n <label class=\"mb d-b\">Description<input type=\"text\" id=\"newDescription\"></label>\n <label class=\"mb d-b\"><input type=\"checkbox\" id=\"newPublic\"> Public (anyone can read it, without signing in)</label>\n </div>\n `, {\n title: 'Create a setting',\n confirmText: 'Create',\n cancelText: 'Cancel',\n confirmAction: async () => {\n const read = id => dialog.querySelector(id);\n const name = read('#newName').value.trim();\n const type = read('#newType').value;\n if(!name){ Toast.error('Give the setting a name'); return; }\n if(name.includes(':')){ Toast.error('The name cannot contain \":\"'); return; }\n let value;\n try {\n value = read('#newValue').value === '' ? DEFAULTS[type] : coerce(read('#newValue').value, type);\n } catch {\n Toast.error(`That is not a valid ${type} value`);\n return;\n }\n const [error] = await setSetting('custom', name, { value, type, isPublic: type !== 'secret' && read('#newPublic').checked, description: read('#newDescription').value.trim() });\n if(error){ Toast.error(error.msg || 'The setting could not be created'); return; }\n Toast.success('Setting created');\n await this.load();\n },\n });\n dialog.querySelector('#newType').addEventListener('change', event => {\n dialog.querySelector('#newValue').type = event.target.value === 'secret' ? 'password' : 'text';\n });\n };\n\n remove = record => {\n Dialog.confirm(`Delete ${humanize(this.shortName(record))}? This cannot be undone.`, async confirmed => {\n if(!confirmed) return;\n const [error] = await deleteSetting(this.owner, this.shortName(record));\n if(error){ Toast.error(error.msg || 'The setting could not be deleted'); return; }\n Toast.success('Setting deleted');\n await this.load();\n }, { title: 'Delete setting', confirmText: 'Delete', confirmClasses: 'danger ml', cancelText: 'Cancel', cancelClasses: '' });\n };\n\n /*\n Rendering\n */\n renderSetting(record){\n const name = this.shortName(record);\n const value = Object.hasOwn(this.changes, name) && this.changes[name].valid ? this.changes[name].value : record.value;\n const editor = html`<k-setting .setting=${{ name, value, type: record.type, description: record.description }} ?disabled=${!this.canEdit}></k-setting>`;\n if(this.owner !== 'custom' || !this.canEdit) return editor;\n return html`<div class=\"d-f\"><div class=\"flex\">${editor}</div><div><button class=\"danger\" aria-label=${`Delete ${humanize(name)}`} title=\"Delete\" @click=${() => this.remove(record)}><k-icon name=\"delete\"></k-icon></button></div></div>`;\n }\n\n render(){\n if(this.loading) return html`<k-spinner></k-spinner>`;\n if(this.error) return html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>`;\n return html`\n ${this.heading ? html`<h2 class=\"h4\">${this.heading}</h2>` : nothing}\n ${this.owner === 'custom' && this.canEdit ? html`<p class=\"mb\"><button @click=${this.create}><k-icon name=\"add\"></k-icon> Create a setting</button></p>` : nothing}\n ${this.canEdit ? nothing : html`<p class=\"tc-muted mb\">You can see these settings but not change them.</p>`}\n ${this.settings.length ? this.settings.map(record => this.renderSetting(record)) : html`<p class=\"tc-muted mb\">${this.owner === 'custom' ? 'There are no custom settings yet.' : 'There are no settings here.'}</p>`}\n ${this.canEdit && this.settings.length ? html`<button class=\"primary\" ?disabled=${!this.dirty || this.blocked || this.busy} @click=${this.save}>${this.busy ? 'Saving…' : 'Save settings'}</button>` : nothing}\n `;\n }\n\n firstUpdated(){\n this.renderRoot.addEventListener('setting-change', this.record);\n }\n}\n\ncustomElements.define('k-settings-panel', SettingsPanel);\n"],"mappings":"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","ignoreList":[]}
@@ -10,7 +10,7 @@
10
10
  <h1>Emails</h1>
11
11
  <p class="tc-muted mb">Kempo does not send email itself: an email extension (such as kempo-resend or kempo-smtp) does. How long the log is kept, and how many times a failed email is tried again, are on the System tab of <a href="/admin/settings">Settings</a>.</p>
12
12
 
13
- <k-tabs persistent-id="admin-emails">
13
+ <k-tabs persistent-id="admin-emails" hash-routing>
14
14
  <k-tab for="log">Log</k-tab>
15
15
  <k-tab for="templates">Templates</k-tab>
16
16
  <k-tab-content name="log">
@@ -3,13 +3,23 @@
3
3
  -->
4
4
  <page title="Settings - Admin">
5
5
  <content>
6
- <script type="module" src="/admin/components/settings/AdminSettings.js"></script>
6
+ <script type="module" src="/kempo-ui/components/Tabs.js"></script>
7
+ <script type="module" src="/admin/components/settings/SettingsPanel.js"></script>
7
8
 
8
9
  <h1>Settings</h1>
9
10
 
10
- <k-admin-settings>
11
+ <k-tabs active="custom" hash-routing>
12
+ <k-tab for="custom">Custom</k-tab>
13
+ <k-tab for="system">System</k-tab>
11
14
  <location name="admin-settings-tabs" />
12
- </k-admin-settings>
15
+
16
+ <k-tab-content name="custom" class="pt">
17
+ <k-settings-panel owner="custom"></k-settings-panel>
18
+ </k-tab-content>
19
+ <k-tab-content name="system" class="pt">
20
+ <k-settings-panel owner="system"></k-settings-panel>
21
+ </k-tab-content>
22
+ </k-tabs>
13
23
  <location name="admin-settings-scripts" />
14
24
 
15
25
  </content>
@@ -257,6 +257,7 @@ export default config => {
257
257
  ['schema notice', notifySchemaAttention],
258
258
  ['jobs', () => registerJobs({ owner: 'kempo', jobs: [
259
259
  { name: 'email-maintenance', every: '1m', callback: './server/utils/email/maintainEmails.js' },
260
+ { name: 'session-cleanup', every: '1h', callback: './server/utils/sessions/pruneExpiredSessions.js' },
260
261
  { name: 'update-check', every: '12h', callback: './server/utils/updates/runUpdateCheck.js' },
261
262
  ] })],
262
263
  ]){
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.9.5",
3
+ "version": "4.9.6",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -61,7 +61,7 @@
61
61
  "peerDependencies": {
62
62
  "kempo-css": ">=2.2.5",
63
63
  "kempo-server": ">=3.4.0",
64
- "kempo-ui": ">=0.6.0"
64
+ "kempo-ui": ">=0.6.2"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@electric-sql/pglite": "^0.5.9",
@@ -0,0 +1,32 @@
1
+ import { lt } from 'drizzle-orm';
2
+ import db from '../../db/index.js';
3
+ import { session } from '../../db/schema.js';
4
+ import getSetting from '../settings/getSetting.js';
5
+
6
+ /*
7
+ Deletes every session that expired more than `system:session_cleanup_hours` ago (default 24), for every user.
8
+ Run hourly by the scheduler (middleware/kempo.js registers it). 0 keeps expired sessions forever, and the
9
+ per-user "clear expired" button still works either way.
10
+ */
11
+ export default async ({ now = new Date(), hours } = {}) => {
12
+ try {
13
+ let cutoffHours = hours;
14
+
15
+ if(cutoffHours === undefined){
16
+ const [, stored] = await getSetting('system', 'session_cleanup_hours', 24);
17
+ cutoffHours = Number(stored);
18
+ if(!Number.isFinite(cutoffHours) || cutoffHours < 0) cutoffHours = 24;
19
+ }
20
+
21
+ if(cutoffHours === 0) return [null, { deleted: 0 }];
22
+
23
+ const removed = await db
24
+ .delete(session)
25
+ .where(lt(session.expiresAt, new Date(now.getTime() - cutoffHours * 3600000)))
26
+ .returning({ token: session.token });
27
+
28
+ return [null, { deleted: removed.length }];
29
+ } catch(error) {
30
+ return [{ code: 500, msg: 'Failed to delete expired sessions' }, null];
31
+ }
32
+ };
@@ -12,6 +12,7 @@ const RULES = {
12
12
  'system:email_retention_by_type': value => (value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every(days => whole(days, 0, 3650))
13
13
  ? null
14
14
  : { code: 400, msg: 'Give each kind of email a whole number of days from 0 to 3650, like { "verify-email": 7 }' }),
15
+ 'system:session_cleanup_hours': value => (whole(value, 0, 8760) ? null : { code: 400, msg: 'Hours must be a whole number from 0 to 8760 (one year)' }),
15
16
  'system:email_from': value => (value === '' || isValidSender(value) ? null : { code: 400, msg: 'The sender must look like name@example.com or Shop Name <name@example.com>' }),
16
17
  };
17
18
 
@@ -14,6 +14,7 @@ export const SYSTEM_SETTINGS = [
14
14
  { owner: 'system', name: 'allow_registration', value: true, type: 'boolean', isPublic: true, description: 'Allow new users to register' },
15
15
  { owner: 'system', name: 'require_email_verification', value: false, type: 'boolean', isPublic: false, description: 'Require email verification before account access' },
16
16
  { owner: 'system', name: 'verification_url', value: 'http://localhost:3000/verify-email/{{token}}', type: 'string', isPublic: false, description: 'Email verification URL template' },
17
+ { owner: 'system', name: 'session_cleanup_hours', value: 24, type: 'number', isPublic: false, description: 'Hours after a session expires before it is deleted automatically (0 keeps expired sessions)' },
17
18
  { owner: 'system', name: 'notification_retention_days', value: 90, type: 'number', isPublic: false, description: 'Days to keep notifications before they are deleted (0 keeps them forever)' },
18
19
  { owner: 'system', name: 'password_reset_url', value: 'http://localhost:3000/reset-password/{{token}}', type: 'string', isPublic: false, description: 'Password reset URL template' },
19
20
  { owner: 'system', name: 'email_max_retries', value: 1, type: 'number', isPublic: false, description: 'How many times kempo tries again to send an email that could not be sent, after the first try (0 never retries)' },
@@ -1,7 +0,0 @@
1
- import t from"/kempo-ui/components/ShadowComponent.js";import{html as e}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Tabs.js";import"./SettingsPanel.js";import s from"./humanize.js";export default class n extends t{static properties={extensionTabs:{state:!0}};constructor(){super(),this.extensionTabs=[]}connectedCallback(){super.connectedCallback(),this.extensionTabs=[...this.querySelectorAll(":scope > k-settings-tab")].map((t,e)=>{const n=t.getAttribute("name")||t.getAttribute("label")||`tab-${e}`;return t.slot=`tab-${n}`,t.style.display="block",{id:n,slotName:`tab-${n}`,label:t.getAttribute("label")||s(n),position:Number(t.getAttribute("position"))||500,extension:!0}})}render(){const t=[{id:"custom",label:"Custom",position:100,content:()=>e`<k-settings-panel owner="custom"></k-settings-panel>`},{id:"system",label:"System",position:200,content:()=>e`<k-settings-panel owner="system"></k-settings-panel>`},...this.extensionTabs].sort((t,e)=>t.position-e.position);return e`
2
- <k-tabs>
3
- ${t.map(t=>e`<k-tab for=${t.id}>${t.label}</k-tab>`)}
4
- ${t.map(t=>e`<k-tab-content name=${t.id} class="pt">${t.extension?e`<slot name=${t.slotName}></slot>`:t.content()}</k-tab-content>`)}
5
- </k-tabs>
6
- `}}customElements.define("k-admin-settings",n);
7
- //# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\admin\components\settings\AdminSettings.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"src\\admin\\components\\settings\\AdminSettings.js","names":["ShadowComponent","html","humanize","AdminSettings","static","extensionTabs","state","constructor","super","this","connectedCallback","querySelectorAll","map","$tab","index","name","getAttribute","slot","style","display","id","slotName","label","position","Number","extension","render","tabs","content","sort","a","b","tab","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Tabs.js';\nimport './SettingsPanel.js';\nimport humanize from './humanize.js';\n\n/*\n The admin Settings page, served at /admin/components/settings/AdminSettings.js. Tabs: **Custom** (the site's own settings) and **System**\n (kempo's), then a tab for each extension that chooses to add one. An extension that does not add a tab has nothing here: it manages\n its own settings wherever it likes.\n\n **An extension adds a tab** by filling the `admin-settings-tabs` location of the page with a `k-settings-tab` (and loads what the tab\n uses from the `admin-settings-scripts` location). `position` orders the tabs (Custom is 100, System 200, a tab with none 500):\n\n <k-settings-tab name=\"shipping\" label=\"Shipping\" position=\"500\">\n <k-settings-panel owner=\"kempo-shipping\"></k-settings-panel>\n </k-settings-tab>\n\n The tab holds anything: the generic panel above, a few of the extension's settings with their editors, or a screen of its own.\n*/\nconst DEFAULT_TAB_POSITION = 500;\n\nexport default class AdminSettings extends ShadowComponent {\n static properties = {\n extensionTabs: { state: true },\n };\n\n constructor(){\n super();\n this.extensionTabs = [];\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n this.extensionTabs = [...this.querySelectorAll(':scope > k-settings-tab')].map(($tab, index) => {\n const name = $tab.getAttribute('name') || $tab.getAttribute('label') || `tab-${index}`;\n $tab.slot = `tab-${name}`;\n $tab.style.display = 'block';\n return {\n id: name,\n slotName: `tab-${name}`,\n label: $tab.getAttribute('label') || humanize(name),\n position: Number($tab.getAttribute('position')) || DEFAULT_TAB_POSITION,\n extension: true,\n };\n });\n }\n\n /*\n Rendering\n */\n render(){\n const tabs = [\n { id: 'custom', label: 'Custom', position: 100, content: () => html`<k-settings-panel owner=\"custom\"></k-settings-panel>` },\n { id: 'system', label: 'System', position: 200, content: () => html`<k-settings-panel owner=\"system\"></k-settings-panel>` },\n ...this.extensionTabs,\n ].sort((a, b) => a.position - b.position);\n\n return html`\n <k-tabs>\n ${tabs.map(tab => html`<k-tab for=${tab.id}>${tab.label}</k-tab>`)}\n ${tabs.map(tab => html`<k-tab-content name=${tab.id} class=\"pt\">${tab.extension ? html`<slot name=${tab.slotName}></slot>` : tab.content()}</k-tab-content>`)}\n </k-tabs>\n `;\n }\n}\n\ncustomElements.define('k-admin-settings', AdminSettings);\n"],"mappings":"OAAOA,MAAqB,yDACnBC,MAAY,iCACd,qCACA,4BACAC,MAAc,+BAkBN,MAAMC,UAAsBH,EACzCI,kBAAoB,CAClBC,cAAe,CAAEC,OAAO,IAG1B,WAAAC,GACEC,QACAC,KAAKJ,cAAgB,EACvB,CAKA,iBAAAK,GACEF,MAAME,oBACND,KAAKJ,cAAgB,IAAII,KAAKE,iBAAiB,4BAA4BC,IAAI,CAACC,EAAMC,KACpF,MAAMC,EAAOF,EAAKG,aAAa,SAAWH,EAAKG,aAAa,UAAY,OAAOF,IAG/E,OAFAD,EAAKI,KAAO,OAAOF,IACnBF,EAAKK,MAAMC,QAAU,QACd,CACLC,GAAIL,EACJM,SAAU,OAAON,IACjBO,MAAOT,EAAKG,aAAa,UAAYd,EAASa,GAC9CQ,SAAUC,OAAOX,EAAKG,aAAa,cAzBd,IA0BrBS,WAAW,IAGjB,CAKA,MAAAC,GACE,MAAMC,EAAO,CACX,CAAEP,GAAI,SAAUE,MAAO,SAAUC,SAAU,IAAKK,QAAS,IAAM3B,CAAI,wDACnE,CAAEmB,GAAI,SAAUE,MAAO,SAAUC,SAAU,IAAKK,QAAS,IAAM3B,CAAI,2DAChEQ,KAAKJ,eACRwB,KAAK,CAACC,EAAGC,IAAMD,EAAEP,SAAWQ,EAAER,UAEhC,OAAOtB,CAAI;;UAEL0B,EAAKf,IAAIoB,GAAO/B,CAAI,cAAc+B,EAAIZ,MAAMY,EAAIV;UAChDK,EAAKf,IAAIoB,GAAO/B,CAAI,uBAAuB+B,EAAIZ,iBAAiBY,EAAIP,UAAYxB,CAAI,cAAc+B,EAAIX,mBAAqBW,EAAIJ;;KAGvI,EAGFK,eAAeC,OAAO,mBAAoB/B","ignoreList":[]}