kempo 4.9.4 → 4.9.5

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 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>
@@ -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">Every email the site was asked to send, and what happened to it. Kempo does not send email itself: an email extension (such as kempo-resend or kempo-smtp) does. How long this 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>
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></k-admin-emails>
13
+ <k-tabs persistent-id="admin-emails">
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>
@@ -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 s from"../../../../../server/utils/permissions/currentUserHasPermission.js";import e from"../../../../../server/utils/email/renderEmailTemplate.js";export default async(r,t)=>{const[o,i]=await s(r.cookies.session_token,"system:pages:read");if(o)return t.status(o.code).json({error:o.msg});if(!i)return t.status(403).json({error:"Insufficient permissions"});const[a,n]=await e({template:r.query.template,data:{}});if(a)return t.status(a.code).json({error:a.msg});t.type("html").send(n)};
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 with no data, to open in a tab: what the site would send, with the parts that need data left blank. An\n extension that can do better (real or sample data) serves its own preview and gives it to <k-email-templates> as `preview-url`.\n*/\nexport default async (request, response) => {\n\tconst [permError, hasPermission] = await currentUserHasPermission(request.cookies.session_token, 'system:pages:read');\n\tif(permError) return response.status(permError.code).json({ error: permError.msg });\n\tif(!hasPermission) return response.status(403).json({ error: 'Insufficient permissions' });\n\n\tconst [error, page] = await renderEmailTemplate({ template: request.query.template, data: {} });\n\tif(error) return response.status(error.code).json({ error: error.msg });\n\n\tresponse.type('html').send(page);\n};\n"],"mappings":"OAAOA,MAA8B,6EAC9BC,MAAyB,0EAMjBC,MAAOC,EAASC,KAC9B,MAAOC,EAAWC,SAAuBN,EAAyBG,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,SAAcb,EAAoB,CAAEc,SAAUZ,EAAQa,MAAMD,SAAUE,KAAM,CAAC,IAC3F,GAAGL,EAAO,OAAOR,EAASK,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAEjET,EAASc,KAAK,QAAQC,KAAKL","ignoreList":[]}
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.9.4",
3
+ "version": "4.9.5",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -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
+ };
@@ -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
  };