kempo 4.9.4 → 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.
- package/README.md +1 -1
- package/app-public/account/index.page.html +1 -0
- package/app-public/delete-account/index.page.html +95 -0
- package/app-templates/emails/account-deletion-code.page.html +13 -0
- package/dist/admin/accounts/users/[id]/index.page.html +77 -60
- package/dist/admin/components/EmailTemplates.js +3 -3
- package/dist/admin/components/EmailTemplates.js.map +1 -1
- package/dist/admin/components/Emails.js +1 -1
- package/dist/admin/components/Emails.js.map +1 -1
- package/dist/admin/components/settings/SettingsPanel.js +1 -1
- package/dist/admin/components/settings/SettingsPanel.js.map +1 -1
- package/dist/admin/emails/index.page.html +15 -2
- package/dist/admin/settings/index.page.html +13 -3
- package/dist/kempo/api/auth/account-deletion/confirm/POST.js +2 -0
- package/dist/kempo/api/auth/account-deletion/confirm/POST.js.map +1 -0
- package/dist/kempo/api/auth/account-deletion/request/POST.js +2 -0
- package/dist/kempo/api/auth/account-deletion/request/POST.js.map +1 -0
- package/dist/kempo/api/email-templates/preview/GET.js +1 -1
- package/dist/kempo/api/email-templates/preview/GET.js.map +1 -1
- package/middleware/kempo.js +1 -0
- package/package.json +2 -2
- package/server/db/schema.js +1 -0
- package/server/utils/auth/accountDeletion.js +120 -0
- package/server/utils/email/readEmailSample.js +23 -0
- package/server/utils/sessions/pruneExpiredSessions.js +32 -0
- package/server/utils/settings/settingRules.js +1 -0
- package/server/utils/settings/systemSettings.js +1 -0
- package/server/utils/users/deleteUser.js +20 -2
- package/dist/admin/components/settings/AdminSettings.js +0 -7
- package/dist/admin/components/settings/AdminSettings.js.map +0 -1
package/README.md
CHANGED
|
@@ -376,7 +376,7 @@ Extensions can declare `jobs` in `kempo-config.json` and kempo runs them on a ti
|
|
|
376
376
|
|
|
377
377
|
## Email
|
|
378
378
|
|
|
379
|
-
Kempo does not send email itself. It renders the email, saves it in a log (**Admin > Emails** shows every one, with whether it was sent and why not), and hands it to an **email extension**: `kempo-resend` sends through Resend, `kempo-smtp` through any SMTP server (a Hostinger or Google mailbox, Amazon SES, a Postfix of your own). Install one from Admin > Extensions and enter its settings on the Settings screen; with none installed, emails are logged and fail with "No email extension is installed".
|
|
379
|
+
Kempo does not send email itself. It renders the email, saves it in a log (**Admin > Emails** shows every one, with whether it was sent and why not), and hands it to an **email extension**: `kempo-resend` sends through Resend, `kempo-smtp` through any SMTP server (a Hostinger or Google mailbox, Amazon SES, a Postfix of your own). Install one from Admin > Extensions and enter its settings on the Settings screen; with none installed, emails are logged and fail with "No email extension is installed". **Admin > Emails > Templates** previews and edits the emails kempo sends and any templates the site has added in `templates/emails/` (put a `<name>.sample.json` beside one to give its preview made-up values).
|
|
380
380
|
|
|
381
381
|
The address emails are sent from is the **Email from** setting (`system:email_from`); until it is set, `EMAIL_FROM` from `.env` is used, and it must be on a domain your email service allows. A failed email is tried again (`email_max_retries`, 1 by default) and the log is kept for `email_retention_days` (30 by default; `email_retention_by_type` sets it per kind of email). See [docs/extensions/creating-extensions.md](docs/extensions/creating-extensions.md) to write an email extension.
|
|
382
382
|
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
<location name="account-links" label="Account Links" />
|
|
37
37
|
<button id="changePasswordBtn" class="btn">Change Password</button>
|
|
38
38
|
<k-logout-btn redirect="{{pathToRoot}}login" button-class="btn danger">Logout</k-logout-btn>
|
|
39
|
+
<a href="{{pathToRoot}}delete-account/" class="btn danger">Delete my account</a>
|
|
39
40
|
</div>
|
|
40
41
|
</div>
|
|
41
42
|
</main>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
owner: system
|
|
3
|
+
title: Delete your account
|
|
4
|
+
description: Delete your account and the data this site keeps about you
|
|
5
|
+
-->
|
|
6
|
+
<page title="Delete your account">
|
|
7
|
+
<content>
|
|
8
|
+
<main class="p">
|
|
9
|
+
<div style="max-width: 600px; margin: 0 auto;">
|
|
10
|
+
<h2>Delete your account</h2>
|
|
11
|
+
|
|
12
|
+
<div id="done" class="card mb" hidden>
|
|
13
|
+
<h4>Your account has been deleted</h4>
|
|
14
|
+
<p>Your name, email address, password and sign-in details are gone, and so is everything else this site kept about you. Thank you for having been here.</p>
|
|
15
|
+
<a href="{{pathToRoot}}">Back to the home page</a>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div id="form">
|
|
19
|
+
<p>This removes your account and everything this site keeps about you: your name, your email address, your password, your sign-in links to other providers, and your sessions. Other parts of this site that keep information about you are told, so they remove theirs too. It cannot be undone.</p>
|
|
20
|
+
<p class="tc-muted">We send a six-digit code to the email address on your account. Entering it proves that you control that address before anything is deleted.</p>
|
|
21
|
+
|
|
22
|
+
<label class="d-b mbq" for="email">Email address on your account</label>
|
|
23
|
+
<input id="email" type="email" autocomplete="email" class="full mb" />
|
|
24
|
+
<button id="send" class="primary" type="button">Send me a code</button>
|
|
25
|
+
|
|
26
|
+
<div id="codeStep" hidden class="mt">
|
|
27
|
+
<label class="d-b mbq" for="code">Code<small class="d-b tc-muted">Six digits. It works for 30 minutes.</small></label>
|
|
28
|
+
<input id="code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="full mb" />
|
|
29
|
+
<div class="d-f">
|
|
30
|
+
<button id="delete" class="danger mr" type="button" disabled>Delete my account</button>
|
|
31
|
+
<button id="resend" class="link" type="button">Send a new code</button>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</main>
|
|
37
|
+
</content>
|
|
38
|
+
<content location="scripts">
|
|
39
|
+
<script type="module">
|
|
40
|
+
import Toast from '{{pathToRoot}}kempo-ui/components/Toast.js';
|
|
41
|
+
|
|
42
|
+
const email = document.getElementById('email');
|
|
43
|
+
const code = document.getElementById('code');
|
|
44
|
+
const send = document.getElementById('send');
|
|
45
|
+
const resend = document.getElementById('resend');
|
|
46
|
+
const remove = document.getElementById('delete');
|
|
47
|
+
const post = async (path, body) => {
|
|
48
|
+
const response = await fetch(`{{pathToRoot}}kempo/api/auth/account-deletion/${path}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify(body)
|
|
52
|
+
});
|
|
53
|
+
return { ok: response.ok, body: await response.json().catch(() => ({})) };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const askForCode = async () => {
|
|
57
|
+
const result = await post('request', { email: email.value.trim() });
|
|
58
|
+
if(!result.ok){
|
|
59
|
+
Toast.error(result.body.error || 'The code could not be sent');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
document.getElementById('codeStep').hidden = false;
|
|
63
|
+
Toast.success('If an account uses that address, a code is on its way');
|
|
64
|
+
code.focus();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
send.addEventListener('click', askForCode);
|
|
68
|
+
resend.addEventListener('click', askForCode);
|
|
69
|
+
code.addEventListener('input', () => {
|
|
70
|
+
code.value = code.value.replace(/\D/g, '');
|
|
71
|
+
remove.disabled = code.value.length !== 6;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
remove.addEventListener('click', async () => {
|
|
75
|
+
remove.disabled = true;
|
|
76
|
+
const result = await post('confirm', { email: email.value.trim(), code: code.value });
|
|
77
|
+
if(!result.ok){
|
|
78
|
+
Toast.error(result.body.error || 'That did not work');
|
|
79
|
+
remove.disabled = code.value.length !== 6;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
document.getElementById('form').hidden = true;
|
|
83
|
+
document.getElementById('done').hidden = false;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch('{{pathToRoot}}kempo/api/auth/session');
|
|
88
|
+
const data = await response.json();
|
|
89
|
+
if(data.user?.email) email.value = data.user.email;
|
|
90
|
+
} catch {
|
|
91
|
+
/* not signed in, or no session to read: the person types the address */
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
</content>
|
|
95
|
+
</page>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
owner: system
|
|
3
|
+
-->
|
|
4
|
+
<page template="email" title="Your code to delete your account">
|
|
5
|
+
<content>
|
|
6
|
+
<h2>Delete your account</h2>
|
|
7
|
+
<if condition="name"><p>Hi {{name}},</p></if>
|
|
8
|
+
<if condition="!name"><p>Hello,</p></if>
|
|
9
|
+
<p>Enter this code to delete your account. Deleting it removes your name, email address, password and everything else this site keeps about you.</p>
|
|
10
|
+
<h1 class="ta-center mb">{{code}}</h1>
|
|
11
|
+
<p class="tc-muted">The code works for {{minutes}} minutes and only once. If you did not ask for it, ignore this email: nothing will be deleted.</p>
|
|
12
|
+
</content>
|
|
13
|
+
</page>
|
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
88
|
-
<
|
|
89
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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>`:
|
|
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
|
-
`:
|
|
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","
|
|
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":"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","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":"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,0BAC5G,MAAMC,EAAON,KAAKO,QAAQ,kBAElBD,GAAQA,EAAKE,OAAQR,KAAKS,OACzBT,KAAKO,QAAQ,WAAWG,iBAAiB,gBAAiB,EAAGC,aAC7DA,EAAOC,MAAQN,EAAKO,MAAMb,KAAKS,QAEtC,CAKAA,KAAOK,UACLd,KAAKR,SAAU,EACf,MAAOC,EAAOU,SAAcjC,EAAW,CAAEoB,OAAQU,KAAKV,aAAUyB,EAAWxB,OAAQS,KAAKT,aAAUwB,EAAWC,MAtD/F,GAsDiH3B,OAAQW,KAAKX,SAC5IW,KAAKR,SAAU,EACZC,EACDO,KAAKP,MAAQA,EAAMwB,KAAO,kCAG5BjB,KAAKP,MAAQ,GACbO,KAAKd,OAASiB,EAAKjB,OACnBc,KAAKZ,MAAQe,EAAKf,QAGpB8B,OAASJ,UACPK,OAAOC,OAAOpB,KAAMqB,EAAS,CAAEhC,OAAQ,UACjCW,KAAKS,QAGba,KAAOR,UACLd,KAAKX,OAASkC,KAAKC,IAAI,EAAGxB,KAAKX,OAvEjB,GAuE0BoC,SAClCzB,KAAKS,QAMbiB,KAAOZ,UACL,MAAOrB,EAAOkC,SAAexD,EAASyD,GACnCnC,EACDxB,EAAMwB,MAAMA,EAAMwB,MAGpBjB,KAAKL,KAAOgC,QACN3B,KAAK6B,eACX7B,KAAK8B,WAAWC,cAAc,gBAAgBpC,SAGhDqC,MAAQlB,UACNd,KAAKH,MAAO,EACZ,MAAOJ,EAAOwC,SAAgB7D,EAAW4B,KAAKL,KAAKiC,IACnD5B,KAAKH,MAAO,EACTJ,EAAOxB,EAAMwB,MAAMA,EAAMwB,KACpBgB,EAAOzD,KAAMP,EAAMiE,QAAQ,sBAC9BjE,EAAMwB,MAAMwC,EAAOxC,OAAS,+BACjCO,KAAK8B,WAAWC,cAAc,gBAAgBI,cACxCnC,KAAKS,QAMb2B,SAAW,IAAMpC,KAAK8B,WAAWC,cAAc,eAAepC,OAE9D0C,YAAcC,IACZA,EAAMC,gBAAiB,EACvBvC,KAAKwC,YAGPA,SAAW1B,UACTd,KAAKH,MAAO,EACZ,MAAOJ,EAAOwC,SAAgB5D,EAAc2B,KAAKJ,OAAO6C,QACxDzC,KAAKH,MAAO,EACTJ,EACDxB,EAAMwB,MAAMA,EAAMwB,MAGpBjB,KAAK8B,WAAWC,cAAc,eAAeI,QAC1CF,EAAOzD,KAAMP,EAAMiE,QAAQ,4BAA4BlC,KAAKJ,OAAO6C,UACjExE,EAAMwB,MAAMwC,EAAOxC,OAAS,0CAC3BO,KAAKS,SAMb,YAAAiC,GACE,MAAMC,EAAO3C,KAAKL,KAClB,OAAIgD,EACG5E,CAAI;;gDAEiC4E,EAAKC;;wCAEbrE,EAAaoE,EAAKrD,SAAW,MAAMqD,EAAKrD,kBAAkBqD,EAAKE,SAAW9E,CAAI,MAAM4E,EAAKE,YAA8B,IAAlBF,EAAKE,SAAiB,MAAQ,UAAY7E,IAAU2E,EAAKG,SAAW/E,CAAI,cAAc4E,EAAKG,WAAa9E;qCAChN2E,EAAKI,4BAA4BJ,EAAKK,oCAAoCL,EAAKM,kBAAkBrE,EAAK+D,EAAKO,aAAaP,EAAKQ,OAAS,WAAWvE,EAAK+D,EAAKQ,UAAY;YAChMR,EAAKS,UAAYrF,CAAI,wCAAwC4E,EAAKS,gBAAkBpF;YACpFgC,KAAKN,WAAa,CAAC,SAAU,UAAUW,SAASsC,EAAKrD,QAAUvB,CAAI,mDAAmDiC,KAAKH,eAAeG,KAAKgC,SAAShC,KAAKH,KAAO,UAAY,2BAA6B7B;yDAChK2E,EAAK5E;;;MATzCC,CAanB,CAEA,UAAAqF,GACE,OAAOtF,CAAI;+CACgCiC,KAAKH,KAAO,WAAa,sFAAsFG,KAAKqC;;;uEAG5FrC,KAAKJ,iBAAiB0C,IAAWtC,KAAKJ,OAAS0C,EAAMgB,OAAOzE;;;;KAKjI,CAEA,MAAA0E,GACE,MAAMC,EAAOjC,KAAKkC,IAAIzD,KAAKX,OA7Jb,GA6JiCW,KAAKZ,OACpD,OAAOrB,CAAI;;;4BAGauE,GAAStC,KAAKkB,OAAO,CAAE5B,OAAQgD,EAAMgB,OAAOzE;cAC1D,CAAC,GAAI,SAAU,UAAW,OAAQ,UAAU6E,IAAIpE,GAAUvB,CAAI,iBAAiBuB,eAAoBU,KAAKV,SAAWA,KAAUA,GAAU;;;;yEAI5EU,KAAKT,kBAAkB+C,GAAStC,KAAKkB,OAAO,CAAE3B,OAAQ+C,EAAMgB,OAAOzE;;UAElImB,KAAKN,UAAY3B,CAAI,6BAA6BiC,KAAKoC,sCAAwCpE;;QAEjGgC,KAAKP,MAAQ1B,CAAI,qCAAqCiC,KAAKP,YAAczB;QACzEgC,KAAKR,QAAUzB,CAAI,0BAA4BiC,KAAKd,OAAOyE,OAAS5F,CAAI;;;mBAG7DiC,KAAKd,OAAOwE,IAAIf,GAAQ5E,CAAI;gCACfQ,EAAaoE,EAAKrD,SAAW,MAAMqD,EAAKrD;kBACtDqD,EAAKI;qCACcT,IAAWA,EAAMsB,iBAAkB5D,KAAK0B,KAAKiB,EAAKf,QAAUe,EAAKC,cAAcD,EAAKS,UAAYrF,CAAI,+BAA+B4E,EAAKS,mBAAqBpF;mCAC/J2E,EAAKM;mCACLrE,EAAK+D,EAAKO;;;8CAGiB,IAAhBlD,KAAKX,iBAAuB,IAAMW,KAAKsB,MAAM,8CAA8CtB,KAAKX,OAAS,KAAKmE,QAAWxD,KAAKZ,iCAAiCoE,GAAQxD,KAAKZ,gBAAgB,IAAMY,KAAKsB,KAAK;QAChPvD,CAAI;QACNiC,KAAK0C;QACL1C,KAAKqD;KAEX,EAGFQ,eAAeC,OAAO,iBAAkB9E","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":[]}
|
|
@@ -4,11 +4,24 @@
|
|
|
4
4
|
<page title="Emails - Admin">
|
|
5
5
|
<content>
|
|
6
6
|
<script type="module" src="/admin/components/Emails.js"></script>
|
|
7
|
+
<script type="module" src="/admin/components/EmailTemplates.js"></script>
|
|
8
|
+
<script type="module" src="/kempo-ui/components/Tabs.js"></script>
|
|
7
9
|
|
|
8
10
|
<h1>Emails</h1>
|
|
9
|
-
<p class="tc-muted mb">
|
|
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>
|
|
10
12
|
|
|
11
|
-
<k-admin-emails
|
|
13
|
+
<k-tabs persistent-id="admin-emails" hash-routing>
|
|
14
|
+
<k-tab for="log">Log</k-tab>
|
|
15
|
+
<k-tab for="templates">Templates</k-tab>
|
|
16
|
+
<k-tab-content name="log">
|
|
17
|
+
<p class="tc-muted mb">Every email the site was asked to send, and what happened to it.</p>
|
|
18
|
+
<k-admin-emails></k-admin-emails>
|
|
19
|
+
</k-tab-content>
|
|
20
|
+
<k-tab-content name="templates">
|
|
21
|
+
<p class="tc-muted mb">The emails this site sends: kempo's own and any the site has added. Preview draws one the way it would be sent, with made-up values when the template has a <code>.sample.json</code> beside it. Edit one to reword it; your copy is used from the next email on and Reset to default brings the packaged one back.</p>
|
|
22
|
+
<k-email-templates></k-email-templates>
|
|
23
|
+
</k-tab-content>
|
|
24
|
+
</k-tabs>
|
|
12
25
|
|
|
13
26
|
</content>
|
|
14
27
|
</page>
|
|
@@ -3,13 +3,23 @@
|
|
|
3
3
|
-->
|
|
4
4
|
<page title="Settings - Admin">
|
|
5
5
|
<content>
|
|
6
|
-
<script type="module" src="/
|
|
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-
|
|
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
|
-
|
|
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>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{confirmAccountDeletion as o}from"../../../../../../server/utils/auth/accountDeletion.js";export default async(e,t)=>{const[r,s]=await o({email:e.body?.email,code:e.body?.code});if(r)return t.status(r.code).json({error:r.msg});t.json(s)};
|
|
2
|
+
//# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\kempo\api\auth\account-deletion\confirm\POST.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"src\\kempo\\api\\auth\\account-deletion\\confirm\\POST.js","names":["confirmAccountDeletion","async","request","response","error","result","email","body","code","status","json","msg"],"sources":["0"],"sourcesContent":["import { confirmAccountDeletion } from '../../../../../../server/utils/auth/accountDeletion.js';\n\n/* POST /kempo/api/auth/account-deletion/confirm { email, code } — deletes the account when the code is right. */\nexport default async (request, response) => {\n const [error, result] = await confirmAccountDeletion({ email: request.body?.email, code: request.body?.code });\n if(error) return response.status(error.code).json({ error: error.msg });\n response.json(result);\n};\n"],"mappings":"iCAASA,MAA8B,wEAGxBC,MAAOC,EAASC,KAC7B,MAAOC,EAAOC,SAAgBL,EAAuB,CAAEM,MAAOJ,EAAQK,MAAMD,MAAOE,KAAMN,EAAQK,MAAMC,OACvG,GAAGJ,EAAO,OAAOD,EAASM,OAAOL,EAAMI,MAAME,KAAK,CAAEN,MAAOA,EAAMO,MACjER,EAASO,KAAKL","ignoreList":[]}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{requestAccountDeletion as t}from"../../../../../../server/utils/auth/accountDeletion.js";export default async(e,o)=>{const[r,s]=await t({email:e.body?.email});if(r)return o.status(r.code).json({error:r.msg});o.json(s)};
|
|
2
|
+
//# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\kempo\api\auth\account-deletion\request\POST.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"src\\kempo\\api\\auth\\account-deletion\\request\\POST.js","names":["requestAccountDeletion","async","request","response","error","result","email","body","status","code","json","msg"],"sources":["0"],"sourcesContent":["import { requestAccountDeletion } from '../../../../../../server/utils/auth/accountDeletion.js';\n\n/* POST /kempo/api/auth/account-deletion/request { email } — emails a code to the account, if there is one. */\nexport default async (request, response) => {\n const [error, result] = await requestAccountDeletion({ email: request.body?.email });\n if(error) return response.status(error.code).json({ error: error.msg });\n response.json(result);\n};\n"],"mappings":"iCAASA,MAA8B,wEAGxBC,MAAOC,EAASC,KAC7B,MAAOC,EAAOC,SAAgBL,EAAuB,CAAEM,MAAOJ,EAAQK,MAAMD,QAC5E,GAAGF,EAAO,OAAOD,EAASK,OAAOJ,EAAMK,MAAMC,KAAK,CAAEN,MAAOA,EAAMO,MACjER,EAASO,KAAKL","ignoreList":[]}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import
|
|
1
|
+
import e from"../../../../../server/utils/permissions/currentUserHasPermission.js";import s from"../../../../../server/utils/email/renderEmailTemplate.js";import r from"../../../../../server/utils/email/readEmailSample.js";export default async(t,a)=>{const[i,o]=await e(t.cookies.session_token,"system:pages:read");if(i)return a.status(i.code).json({error:i.msg});if(!o)return a.status(403).json({error:"Insufficient permissions"});const[m,n]=await s({template:t.query.template,data:await r({template:t.query.template})});if(m)return a.status(m.code).json({error:m.msg});a.type("html").send(n)};
|
|
2
2
|
//# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\kempo\api\email-templates\preview\GET.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"src\\kempo\\api\\email-templates\\preview\\GET.js","names":["currentUserHasPermission","renderEmailTemplate","async","request","response","permError","hasPermission","cookies","session_token","status","code","json","error","msg","page","template","query","data","type","send"],"sources":["0"],"sourcesContent":["import currentUserHasPermission from '../../../../../server/utils/permissions/currentUserHasPermission.js';\nimport renderEmailTemplate from '../../../../../server/utils/email/renderEmailTemplate.js';\n\n/*\n An email template drawn
|
|
1
|
+
{"version":3,"file":"src\\kempo\\api\\email-templates\\preview\\GET.js","names":["currentUserHasPermission","renderEmailTemplate","readEmailSample","async","request","response","permError","hasPermission","cookies","session_token","status","code","json","error","msg","page","template","query","data","type","send"],"sources":["0"],"sourcesContent":["import currentUserHasPermission from '../../../../../server/utils/permissions/currentUserHasPermission.js';\r\nimport renderEmailTemplate from '../../../../../server/utils/email/renderEmailTemplate.js';\r\nimport readEmailSample from '../../../../../server/utils/email/readEmailSample.js';\r\n\r\n/*\r\n An email template drawn to open in a tab: what the site would send. It is filled in with the template's `<name>.sample.json` when\r\n it has one (made-up values, kept beside the template), and the parts that need data are left blank when it has none. An extension\r\n that can do better (its own real data) serves its own preview and gives it to <k-email-templates> as `preview-url`.\r\n*/\r\nexport default async (request, response) => {\r\n\tconst [permError, hasPermission] = await currentUserHasPermission(request.cookies.session_token, 'system:pages:read');\r\n\tif(permError) return response.status(permError.code).json({ error: permError.msg });\r\n\tif(!hasPermission) return response.status(403).json({ error: 'Insufficient permissions' });\r\n\r\n\tconst [error, page] = await renderEmailTemplate({ template: request.query.template, data: await readEmailSample({ template: request.query.template }) });\r\n\tif(error) return response.status(error.code).json({ error: error.msg });\r\n\r\n\tresponse.type('html').send(page);\r\n};\r\n"],"mappings":"OAAOA,MAA8B,6EAC9BC,MAAyB,kEACzBC,MAAqB,sEAObC,MAAOC,EAASC,KAC9B,MAAOC,EAAWC,SAAuBP,EAAyBI,EAAQI,QAAQC,cAAe,qBACjG,GAAGH,EAAW,OAAOD,EAASK,OAAOJ,EAAUK,MAAMC,KAAK,CAAEC,MAAOP,EAAUQ,MAC7E,IAAIP,EAAe,OAAOF,EAASK,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAE7D,MAAOA,EAAOE,SAAcd,EAAoB,CAAEe,SAAUZ,EAAQa,MAAMD,SAAUE,WAAYhB,EAAgB,CAAEc,SAAUZ,EAAQa,MAAMD,aAC1I,GAAGH,EAAO,OAAOR,EAASK,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAEjET,EAASc,KAAK,QAAQC,KAAKL","ignoreList":[]}
|
package/middleware/kempo.js
CHANGED
|
@@ -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.
|
|
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.
|
|
64
|
+
"kempo-ui": ">=0.6.2"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@electric-sql/pglite": "^0.5.9",
|
package/server/db/schema.js
CHANGED
|
@@ -33,6 +33,7 @@ export const verificationToken = pgTable('verificationToken', {
|
|
|
33
33
|
.references(() => user.id),
|
|
34
34
|
type: text('type').notNull(),
|
|
35
35
|
expiresAt: timestamp('expiresAt').notNull(),
|
|
36
|
+
attempts: integer('attempts').notNull().default(0),
|
|
36
37
|
createdAt: timestamp('createdAt').notNull(),
|
|
37
38
|
});
|
|
38
39
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { and, count, eq, gt, sql } from 'drizzle-orm';
|
|
3
|
+
import db from '../../db/index.js';
|
|
4
|
+
import { user, userGroup, verificationToken } from '../../db/schema.js';
|
|
5
|
+
import sendEmailFromTemplate from '../email/sendEmailFromTemplate.js';
|
|
6
|
+
import deleteUser from '../users/deleteUser.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
A person deletes their own account. No login is needed: they give the email address on the account,
|
|
10
|
+
and a six-digit code is sent there. Typing the code back proves they control that mailbox, and only then
|
|
11
|
+
is the account deleted. Someone who does not hold the mailbox can request codes for it but cannot use them.
|
|
12
|
+
|
|
13
|
+
Nothing in the answer tells a stranger whether an account exists for an address: the same reply comes back
|
|
14
|
+
either way, and the email is sent only when there is an account to delete.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const CODE_MINUTES = 30;
|
|
18
|
+
const MAX_ATTEMPTS = 5;
|
|
19
|
+
const COOLDOWN_MS = 60 * 1000;
|
|
20
|
+
const TYPE = 'account_deletion';
|
|
21
|
+
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
22
|
+
|
|
23
|
+
const tokenFor = (userId, code) => `${TYPE}:${crypto.createHash('sha256').update(`${userId}:${code}`).digest('hex')}`;
|
|
24
|
+
|
|
25
|
+
const normalised = email => String(email ?? '').trim().toLowerCase();
|
|
26
|
+
|
|
27
|
+
const accountFor = async email => {
|
|
28
|
+
const [found] = await db.select({ id: user.id, email: user.email, name: user.name })
|
|
29
|
+
.from(user)
|
|
30
|
+
.where(sql`lower(${user.email}) = ${email}`)
|
|
31
|
+
.limit(1);
|
|
32
|
+
return found ?? null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
Sends a code to the account for `email`, if there is one. Always answers the same way.
|
|
37
|
+
*/
|
|
38
|
+
export const requestAccountDeletion = async ({ email, now = new Date() }) => {
|
|
39
|
+
const address = normalised(email);
|
|
40
|
+
if(!EMAIL_PATTERN.test(address)){
|
|
41
|
+
return [{ code: 400, msg: 'Enter the email address on your account' }, null];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const found = await accountFor(address);
|
|
45
|
+
if(!found) return [null, { sent: true }];
|
|
46
|
+
|
|
47
|
+
const [recent] = await db.select({ createdAt: verificationToken.createdAt }).from(verificationToken)
|
|
48
|
+
.where(and(eq(verificationToken.userId, found.id), eq(verificationToken.type, TYPE)))
|
|
49
|
+
.limit(1);
|
|
50
|
+
if(recent && now - recent.createdAt < COOLDOWN_MS) return [null, { sent: true }];
|
|
51
|
+
|
|
52
|
+
await db.delete(verificationToken).where(and(eq(verificationToken.userId, found.id), eq(verificationToken.type, TYPE)));
|
|
53
|
+
|
|
54
|
+
const code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
|
55
|
+
await db.insert(verificationToken).values({
|
|
56
|
+
token: tokenFor(found.id, code),
|
|
57
|
+
userId: found.id,
|
|
58
|
+
type: TYPE,
|
|
59
|
+
expiresAt: new Date(now.getTime() + CODE_MINUTES * 60 * 1000),
|
|
60
|
+
attempts: 0,
|
|
61
|
+
createdAt: now,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const [mailError] = await sendEmailFromTemplate({
|
|
65
|
+
to: found.email,
|
|
66
|
+
subject: 'Your code to delete your account',
|
|
67
|
+
template: 'account-deletion-code',
|
|
68
|
+
data: { name: found.name || '', code, minutes: CODE_MINUTES },
|
|
69
|
+
});
|
|
70
|
+
// Saved in the email log and sent when a sender is ready, so that still counts as sent.
|
|
71
|
+
if(mailError && !mailError.emailId){
|
|
72
|
+
await db.delete(verificationToken).where(eq(verificationToken.token, tokenFor(found.id, code)));
|
|
73
|
+
return [{ code: 502, msg: 'The email could not be sent right now. Try again in a minute' }, null];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [null, { sent: true }];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
Checks the code and, if it is right, deletes the account. A wrong code counts as an attempt; after
|
|
81
|
+
MAX_ATTEMPTS the code is dead and a new one has to be asked for.
|
|
82
|
+
*/
|
|
83
|
+
export const confirmAccountDeletion = async ({ email, code, now = new Date() }) => {
|
|
84
|
+
const address = normalised(email);
|
|
85
|
+
const given = String(code ?? '').replace(/\s/g, '');
|
|
86
|
+
const wrong = [{ code: 400, msg: 'That code is not right' }, null];
|
|
87
|
+
|
|
88
|
+
const found = await accountFor(address);
|
|
89
|
+
if(!found || !/^\d{6}$/.test(given)) return wrong;
|
|
90
|
+
|
|
91
|
+
const [row] = await db.select().from(verificationToken)
|
|
92
|
+
.where(and(eq(verificationToken.userId, found.id), eq(verificationToken.type, TYPE)))
|
|
93
|
+
.limit(1);
|
|
94
|
+
if(!row) return [{ code: 400, msg: 'No code has been sent for this account. Ask for a new one' }, null];
|
|
95
|
+
if(row.expiresAt < now) return [{ code: 400, msg: 'That code has expired. Ask for a new one' }, null];
|
|
96
|
+
if(row.attempts >= MAX_ATTEMPTS) return [{ code: 429, msg: 'Too many wrong tries. Ask for a new code' }, null];
|
|
97
|
+
|
|
98
|
+
if(row.token !== tokenFor(found.id, given)){
|
|
99
|
+
await db.update(verificationToken).set({ attempts: row.attempts + 1 }).where(eq(verificationToken.token, row.token));
|
|
100
|
+
return wrong;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
The last administrator cannot delete themselves: the site would be left with nobody who can run it.
|
|
105
|
+
*/
|
|
106
|
+
const [admin] = await db.select({ userId: userGroup.userId }).from(userGroup)
|
|
107
|
+
.where(eq(userGroup.groupName, 'system:Administrators'))
|
|
108
|
+
.limit(1);
|
|
109
|
+
if(admin?.userId === found.id){
|
|
110
|
+
const [{ total }] = await db.select({ total: count() }).from(userGroup).where(eq(userGroup.groupName, 'system:Administrators'));
|
|
111
|
+
if(Number(total) <= 1){
|
|
112
|
+
return [{ code: 409, msg: 'You are the only administrator, so this account cannot be deleted. Make someone else an administrator first' }, null];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const [deleteError] = await deleteUser(found.id);
|
|
117
|
+
if(deleteError) return [deleteError, null];
|
|
118
|
+
|
|
119
|
+
return [null, { deleted: true }];
|
|
120
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { defaultEmailsDir, locateTemplate, siteEmailsDir } from './emailTemplatePaths.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The made-up values a template is previewed with: `<name>.sample.json` beside the template, in the site's
|
|
7
|
+
`templates/emails` (which wins) or the packaged one. A template with none, or one whose file is not a JSON
|
|
8
|
+
object, previews with no data, which is what it did before. Always resolves to an object.
|
|
9
|
+
*/
|
|
10
|
+
export default async ({ template } = {}) => {
|
|
11
|
+
const located = locateTemplate(template);
|
|
12
|
+
if(!located) return {};
|
|
13
|
+
|
|
14
|
+
for(const dir of [siteEmailsDir(located.owner), defaultEmailsDir(located.owner)]){
|
|
15
|
+
try {
|
|
16
|
+
const sample = JSON.parse(await readFile(join(dir, `${located.name}.sample.json`), 'utf8'));
|
|
17
|
+
if(sample && typeof sample === 'object' && !Array.isArray(sample)) return sample;
|
|
18
|
+
} catch {
|
|
19
|
+
/* no file here, or not JSON: try the next place */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {};
|
|
23
|
+
};
|
|
@@ -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,19 +1,37 @@
|
|
|
1
1
|
import db from '../../db/index.js';
|
|
2
2
|
import { user, session, verificationToken, userGroup } from '../../db/schema.js';
|
|
3
3
|
import { eq } from 'drizzle-orm';
|
|
4
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
4
5
|
|
|
6
|
+
/*
|
|
7
|
+
Deletes a user and everything kempo keeps for them: their sessions, codes, group memberships and the
|
|
8
|
+
account itself. Then fires `user:deleted` with `{ id, email, name }` so every extension can remove what it
|
|
9
|
+
keeps about that person. Those extensions own their own data; kempo only tells them.
|
|
10
|
+
|
|
11
|
+
The hook runs after the user has gone, and a handler that fails does not undo the deletion: the
|
|
12
|
+
notification contract keeps going and records the failure.
|
|
13
|
+
*/
|
|
5
14
|
export default async (id) => {
|
|
6
15
|
if(!id){
|
|
7
16
|
return [{ code: 400, msg: 'User ID is required' }, null];
|
|
8
17
|
}
|
|
9
|
-
|
|
18
|
+
|
|
19
|
+
let removed = null;
|
|
10
20
|
try {
|
|
21
|
+
const [existing] = await db.select({ id: user.id, email: user.email, name: user.name }).from(user).where(eq(user.id, id)).limit(1);
|
|
22
|
+
removed = existing ?? null;
|
|
23
|
+
|
|
11
24
|
await db.delete(session).where(eq(session.userId, id));
|
|
12
25
|
await db.delete(verificationToken).where(eq(verificationToken.userId, id));
|
|
13
26
|
await db.delete(userGroup).where(eq(userGroup.userId, id));
|
|
14
27
|
await db.delete(user).where(eq(user.id, id));
|
|
15
|
-
return [null, { id }];
|
|
16
28
|
} catch(error){
|
|
17
29
|
return [{ code: 500, msg: 'Failed to delete user' }, null];
|
|
18
30
|
}
|
|
31
|
+
|
|
32
|
+
if(removed){
|
|
33
|
+
await triggerHook('user:deleted', { id: removed.id, email: removed.email, name: removed.name });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [null, { id }];
|
|
19
37
|
};
|
|
@@ -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":[]}
|