kempo 4.4.1 → 4.4.2

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
@@ -43,7 +43,12 @@ Your site is running at `http://localhost:3000`.
43
43
 
44
44
  ### Create an Admin User
45
45
 
46
- Register a user through the UI at `/register`, then promote them:
46
+ `npx kempo init` asks how to create the first admin:
47
+
48
+ - **In the terminal** - the wizard prompts for a name, email and password.
49
+ - **In the browser** - visit `/setup` on your new site and fill in the form. The setup page and its API route are deleted from `public/setup/` as soon as the admin is created, and they refuse to run (and delete themselves) if an administrator already exists, so they can only be used once.
50
+
51
+ To promote an existing user instead, register through the UI at `/register`, then run:
47
52
 
48
53
  ```bash
49
54
  node node_modules/kempo/scripts/make-admin.js
@@ -64,6 +69,7 @@ Kempo scaffolds these pages into your `public/` directory. All are fully functio
64
69
  | `/` | Home page |
65
70
  | `/login` | Login form |
66
71
  | `/register` | Registration form |
72
+ | `/setup` | First-run admin setup (deletes itself once an admin exists) |
67
73
  | `/forgot-password` | Password recovery |
68
74
  | `/reset-password/[token]` | Password reset (from email link) |
69
75
  | `/verify-email` | Email verification notice |
@@ -0,0 +1,23 @@
1
+ import { dirname, join } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import hasAdmin from 'kempo/server/utils/setup/hasAdmin.js';
4
+ import removeSetupFiles from 'kempo/server/utils/setup/removeSetupFiles.js';
5
+
6
+ /*
7
+ Tells the setup page whether it is still needed. If an administrator was created some other way
8
+ (init, makeAdmin) the setup files are deleted here, so they never outlive their purpose.
9
+ */
10
+ export default async (request, response) => {
11
+ const [error, adminExists] = await hasAdmin();
12
+
13
+ if(error){
14
+ return response.status(error.code).json({ error: error.msg });
15
+ }
16
+
17
+ if(adminExists){
18
+ await removeSetupFiles({ setupDir: join(dirname(fileURLToPath(import.meta.url)), '..') });
19
+ return response.status(404).json({ error: 'Setup is already complete' });
20
+ }
21
+
22
+ response.json({ available: true });
23
+ };
@@ -0,0 +1,28 @@
1
+ import { dirname, join } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import completeSetup from 'kempo/server/utils/setup/completeSetup.js';
4
+
5
+ /*
6
+ Creates the first administrator and then deletes the whole setup directory, this route included.
7
+ It only ever works once: completeSetup refuses when an administrator already exists.
8
+ */
9
+ export default async (request, response) => {
10
+ try {
11
+ const { name, email, password } = request.body || {};
12
+ const [error, result] = await completeSetup({
13
+ name,
14
+ email,
15
+ password,
16
+ setupDir: join(dirname(fileURLToPath(import.meta.url)), '..')
17
+ });
18
+
19
+ if(error){
20
+ return response.status(error.code).json({ error: error.msg });
21
+ }
22
+
23
+ response.json({ user: result.user });
24
+ } catch(error){
25
+ console.error('Setup error:', error);
26
+ response.status(500).json({ error: 'Internal server error' });
27
+ }
28
+ };
@@ -0,0 +1,59 @@
1
+ <!--
2
+ owner: system
3
+ title: Setup
4
+ description: First-run page that creates the initial administrator and then deletes itself
5
+ -->
6
+ <page title="Set up your site">
7
+ <content>
8
+ <main class="p">
9
+ <div>
10
+ <h2>Welcome to Kempo</h2>
11
+ <p>Create the administrator account for your site. This page deletes itself once you are done.</p>
12
+ <form id="setupForm">
13
+ <div class="mb">
14
+ <label for="name" class="d-b mb-sm">Name</label>
15
+ <input type="text" id="name" name="name" required class="w-full p-sm br">
16
+ </div>
17
+ <div class="mb">
18
+ <label for="email" class="d-b mb-sm">Email</label>
19
+ <input type="email" id="email" name="email" required class="w-full p-sm br">
20
+ </div>
21
+ <div class="mb">
22
+ <label for="password" class="d-b mb-sm">Password</label>
23
+ <input type="password" id="password" name="password" minlength="8" required class="w-full p-sm br">
24
+ </div>
25
+ <button type="submit" class="w-full p-sm br">Create administrator</button>
26
+ </form>
27
+ </div>
28
+ </main>
29
+ </content>
30
+ <content location="scripts">
31
+ <script type="module">
32
+ import Toast from '{{pathToRoot}}kempo-ui/components/Toast.js';
33
+
34
+ const status = await fetch('{{pathToRoot}}setup/api');
35
+ if(!status.ok){
36
+ window.location.href = '{{pathToRoot}}';
37
+ }
38
+
39
+ document.getElementById('setupForm').addEventListener('submit', async (e) => {
40
+ e.preventDefault();
41
+ const response = await fetch('{{pathToRoot}}setup/api', {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({
45
+ name: document.getElementById('name').value,
46
+ email: document.getElementById('email').value,
47
+ password: document.getElementById('password').value
48
+ })
49
+ });
50
+ const body = await response.json();
51
+ if(!response.ok){
52
+ Toast.error(body.error);
53
+ return;
54
+ }
55
+ window.location.href = '{{pathToRoot}}login';
56
+ });
57
+ </script>
58
+ </content>
59
+ </page>
package/bin/cli.js CHANGED
@@ -82,6 +82,7 @@ const printManualSteps = (databaseUrl) => {
82
82
  console.log(' docker compose up -d');
83
83
  console.log(' npx drizzle-kit push');
84
84
  console.log(' node node_modules/kempo/scripts/init-db.js');
85
+ console.log(' Then create your admin at http://localhost:9876/setup, or register and run:');
85
86
  console.log(` DATABASE_URL="${databaseUrl}" node node_modules/kempo/scripts/make-admin.js`);
86
87
  console.log(' npm run dev\n');
87
88
  };
@@ -222,7 +223,19 @@ const databaseUrl = customDatabaseUrl || `postgresql://kempo:${dbPassword}@local
222
223
  const generatedAdminPassword = generatePassword();
223
224
  let adminName, adminEmail, adminPassword;
224
225
 
225
- if(useDefaults){
226
+ const adminSetupChoice = useDefaults ? 'terminal' : (await inquirer.prompt([{
227
+ type: 'select',
228
+ name: 'adminSetup',
229
+ message: 'How would you like to create the first admin user?',
230
+ choices: [
231
+ { name: 'Here in the terminal', value: 'terminal' },
232
+ { name: 'In the browser (a /setup page that deletes itself once used)', value: 'browser' }
233
+ ]
234
+ }])).adminSetup;
235
+
236
+ if(adminSetupChoice === 'browser'){
237
+ // The admin is created on the /setup page instead
238
+ } else if(useDefaults){
226
239
  adminName = 'Admin';
227
240
  adminEmail = 'admin@example.com';
228
241
  adminPassword = generatedAdminPassword;
@@ -245,7 +258,8 @@ if(useDefaults){
245
258
  type: 'password',
246
259
  name: 'adminPassword',
247
260
  message: 'Admin user password (leave blank to generate one):',
248
- mask: '*'
261
+ mask: '*',
262
+ validate: v => !v || v.length >= 8 || 'Password must be at least 8 characters'
249
263
  }
250
264
  ]);
251
265
  adminName = adminAnswers.adminName;
@@ -467,26 +481,26 @@ execSync('node node_modules/kempo/scripts/init-db.js', { cwd: projectDir, stdio:
467
481
  Step 8: Create admin user
468
482
  */
469
483
 
470
- console.log('\n--- Creating admin user ---\n');
471
-
472
484
  process.env.DATABASE_URL = databaseUrl;
473
485
 
474
- const { default: createUser } = await import(pathToFileURL(join(moduleRoot, 'server', 'utils', 'users', 'createUser.js')).href);
475
- const { default: addUserToGroup } = await import(pathToFileURL(join(moduleRoot, 'server', 'utils', 'groups', 'addUserToGroup.js')).href);
486
+ if(adminSetupChoice === 'terminal'){
487
+ console.log('\n--- Creating admin user ---\n');
476
488
 
477
- const [createError, createdUser] = await createUser({
478
- name: adminName,
479
- email: adminEmail,
480
- password: adminPassword,
481
- emailVerified: true
482
- });
489
+ /*
490
+ The same routine the /setup page uses: it creates the admin and then deletes public/setup/, so
491
+ this path leaves no setup page behind. If it fails no admin exists, so the setup page stays.
492
+ */
493
+ const { default: completeSetup } = await import(pathToFileURL(join(moduleRoot, 'server', 'utils', 'setup', 'completeSetup.js')).href);
483
494
 
484
- if(createError){
485
- console.error(`Failed to create admin user: ${createError.msg}`);
486
- } else {
487
- const [groupError] = await addUserToGroup(createdUser.user.id, 'system:Administrators');
488
- if(groupError){
489
- console.error(`Failed to assign admin role: ${groupError.msg}`);
495
+ const [setupError] = await completeSetup({
496
+ name: adminName,
497
+ email: adminEmail,
498
+ password: adminPassword,
499
+ setupDir: join(projectDir, 'public', 'setup')
500
+ });
501
+
502
+ if(setupError){
503
+ console.error(`Failed to create admin user: ${setupError.msg}`);
490
504
  } else {
491
505
  console.log(`Admin user created: ${adminEmail}`);
492
506
  }
@@ -501,10 +515,13 @@ console.log(`
501
515
  ║ Kempo setup complete! ║
502
516
  ╚══════════════════════════════════════════╝
503
517
 
504
- Admin credentials:
518
+ ${adminSetupChoice === 'browser' ? `Create your admin user in the browser:
519
+ http://localhost:9876/setup
520
+ (the setup page deletes itself once you finish)
521
+ ` : `Admin credentials:
505
522
  Email: ${adminEmail}
506
523
  Password: ${adminPassword}
507
- ${emailServiceSkipped ? '\n Note: Email is not configured. Update RESEND_API_KEY in .env when ready.\n' : ''}
524
+ `}${emailServiceSkipped ? '\n Note: Email is not configured. Update RESEND_API_KEY in .env when ready.\n' : ''}
508
525
  Next steps:
509
526
  npm run dev
510
527
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.4.1",
3
+ "version": "4.4.2",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -0,0 +1,49 @@
1
+ import createUser from '../users/createUser.js';
2
+ import addUserToGroup from '../groups/addUserToGroup.js';
3
+ import hasAdmin from './hasAdmin.js';
4
+ import removeSetupFiles from './removeSetupFiles.js';
5
+
6
+ let running = false;
7
+
8
+ export default async ({ name, email, password, setupDir }) => {
9
+ if(!password || password.length < 8){
10
+ return [{ code: 400, msg: 'Password must be at least 8 characters' }, null];
11
+ }
12
+
13
+ /*
14
+ The admin check and the user insert are not one transaction, so two simultaneous requests on a
15
+ fresh install could both pass the check. Setup runs once per install, so a process-level lock
16
+ is enough to close that.
17
+ */
18
+ if(running){
19
+ return [{ code: 409, msg: 'Setup is already in progress' }, null];
20
+ }
21
+ running = true;
22
+
23
+ try {
24
+ const [checkError, adminExists] = await hasAdmin();
25
+ if(checkError){
26
+ return [checkError, null];
27
+ }
28
+ if(adminExists){
29
+ await removeSetupFiles({ setupDir });
30
+ return [{ code: 404, msg: 'Setup is already complete' }, null];
31
+ }
32
+
33
+ const [createError, created] = await createUser({ name, email, password, emailVerified: true });
34
+ if(createError){
35
+ return [createError, null];
36
+ }
37
+
38
+ const [groupError] = await addUserToGroup(created.user.id, 'system:Administrators');
39
+ if(groupError){
40
+ return [groupError, null];
41
+ }
42
+
43
+ const [removeError] = await removeSetupFiles({ setupDir });
44
+
45
+ return [null, { user: created.user, setupFilesRemoved: !removeError }];
46
+ } finally {
47
+ running = false;
48
+ }
49
+ };
@@ -0,0 +1,12 @@
1
+ import db from '../../db/index.js';
2
+ import { userGroup } from '../../db/schema.js';
3
+ import { eq } from 'drizzle-orm';
4
+
5
+ export default async () => {
6
+ try {
7
+ const [found] = await db.select().from(userGroup).where(eq(userGroup.groupName, 'system:Administrators')).limit(1);
8
+ return [null, !!found];
9
+ } catch(error){
10
+ return [{ code: 500, msg: 'Failed to check for an administrator' }, null];
11
+ }
12
+ };
@@ -0,0 +1,24 @@
1
+ import { rm } from 'fs/promises';
2
+
3
+ export default async ({ setupDir }) => {
4
+ if(!setupDir){
5
+ return [{ code: 400, msg: 'Setup directory is required' }, null];
6
+ }
7
+
8
+ try {
9
+ await rm(setupDir, { recursive: true, force: true });
10
+ } catch(error){
11
+ return [{ code: 500, msg: 'Failed to remove the setup files' }, null];
12
+ }
13
+
14
+ /*
15
+ A running server only learns the files are gone on its next scan; without this the deleted
16
+ page and route would keep answering until the process restarts.
17
+ */
18
+ try {
19
+ const { default: rescan } = await import('kempo-server/rescan');
20
+ await rescan();
21
+ } catch {}
22
+
23
+ return [null, { removed: true }];
24
+ };