create-navette 1.0.0-beta.3 → 1.0.0-beta.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/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
7
7
  import { dirname, join, resolve } from 'node:path';
8
8
  import { createInterface } from 'node:readline/promises';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { readSecret } from "./secret.js";
10
11
  import { projectFiles } from "./templates.js";
11
12
  const own = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
12
13
  const args = process.argv.slice(2);
@@ -34,6 +35,27 @@ async function text(question, fallback, given) {
34
35
  return fallback;
35
36
  return (await prompt().question(` ${bold(question)} ${dim(`(${fallback})`)} `)).trim() || fallback;
36
37
  }
38
+ async function secret(question) {
39
+ // Readline would echo the characters: it is closed while the password is typed (`prompt()` opens a new one after).
40
+ rl?.close();
41
+ rl = null;
42
+ return readSecret(` ${bold(question)} `);
43
+ }
44
+ /** The admin's password, typed twice; empty: `null`, one is generated. */
45
+ async function choosePassword() {
46
+ for (;;) {
47
+ const password = await secret(`Admin password ${dim('(at least 8 characters; leave empty to generate one)')}`);
48
+ if (!password)
49
+ return null;
50
+ if (password.length < 8) {
51
+ say(` ${dim('✖')} At least 8 characters: try again.`);
52
+ continue;
53
+ }
54
+ if ((await secret('Confirm the password')) === password)
55
+ return password;
56
+ say(` ${dim('✖')} The passwords do not match: try again.`);
57
+ }
58
+ }
37
59
  async function choose(question, choices, fallback, given) {
38
60
  if (given !== undefined) {
39
61
  const match = choices.find(([value]) => value === given);
@@ -70,6 +92,11 @@ const database = await choose('Database', [['sqlite', 'SQLite — a file, nothin
70
92
  const locale = await choose('Language of the interface', [['en', 'English'], ['fr', 'Français']], 'en', flag('locale'));
71
93
  const install = await confirm('Install the dependencies now?', true, flags['no-install'] ? 'no' : flags.install);
72
94
  const adminEmail = install && database === 'sqlite' ? await text('Admin email address', 'admin@example.com', flag('admin-email')) : null;
95
+ // The admin's password: `--admin-password`, typed twice, or generated (and then shown at the end).
96
+ const givenPassword = flag('admin-password');
97
+ if (givenPassword !== undefined && givenPassword.length < 8)
98
+ fail('--admin-password must be at least 8 characters.');
99
+ const chosenPassword = adminEmail ? (givenPassword ?? (interactive ? await choosePassword() : null)) : null;
73
100
  rl?.close();
74
101
  const options = {
75
102
  name: name.toLowerCase(), appName: name.replace(/[-_.]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
@@ -97,13 +124,16 @@ for (const [file, content] of Object.entries(files)) {
97
124
  say(`\n ✔ Created ${name} (${Object.keys(files).length} files)`);
98
125
  const run = (command, commandArgs) => spawnSync(command, commandArgs, { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' }).status === 0;
99
126
  let adminPassword = null;
127
+ const passwordGenerated = adminEmail !== null && chosenPassword === null;
100
128
  if (install) {
101
129
  say(' … Installing dependencies\n');
102
130
  if (!run('npm', ['install', '--no-fund', '--no-audit']))
103
131
  fail('npm install failed: fix the error above, then run `npm install` in the project.');
104
132
  if (adminEmail) {
105
- adminPassword = flag('admin-password') ?? randomBytes(12).toString('base64url');
106
- if (!run('npx', ['navette', 'make:user', '--admin', '--name=Admin', `--email=${adminEmail}`, `--password=${adminPassword}`]))
133
+ adminPassword = chosenPassword ?? randomBytes(12).toString('base64url');
134
+ // Passed through the environment, not the command line, where other users of the machine could read it.
135
+ const created = spawnSync('npx', ['navette', 'make:user', '--admin', '--name=Admin', `--email=${adminEmail}`], { cwd: target, stdio: 'inherit', shell: process.platform === 'win32', env: { ...process.env, NAVETTE_PASSWORD: adminPassword } }).status === 0;
136
+ if (!created)
107
137
  fail('Creating the admin user failed: run `npx navette make:user --admin` in the project.');
108
138
  }
109
139
  }
@@ -116,7 +146,14 @@ if (database !== 'sqlite')
116
146
  if (!adminPassword)
117
147
  say(' npx navette make:user --admin');
118
148
  say(' npm run dev\n');
119
- say(` Admin panel http://127.0.0.1:${options.port}/admin${adminPassword ? ` ${dim('sign in:')} ${adminEmail} / ${adminPassword}` : ''}`);
149
+ say(` Admin panel http://127.0.0.1:${options.port}/admin`);
120
150
  if (userPanel)
121
151
  say(` User panel http://127.0.0.1:${options.port}/app`);
152
+ if (adminPassword) {
153
+ say(`\n ${bold('Admin account')}\n`);
154
+ say(` Email ${adminEmail}`);
155
+ say(passwordGenerated
156
+ ? ` Password ${bold(adminPassword)} ${dim('generated: copy it now, it is not shown again')}`
157
+ : ` Password ${dim('the one you chose')}`);
158
+ }
122
159
  say(`\n Generate code with ${bold('npx navette make:resource Post --generate')} — see README.md.\n`);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Reads a hidden answer in raw mode: each typed character shows as a dot, so the password never appears on screen.
3
+ * Backspace erases, Enter validates, Ctrl+C quits; escape sequences (arrow keys) are ignored.
4
+ */
5
+ export declare function readSecret(label: string, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream): Promise<string>;
package/lib/secret.js ADDED
@@ -0,0 +1,40 @@
1
+ // Hidden terminal input, for passwords.
2
+ /**
3
+ * Reads a hidden answer in raw mode: each typed character shows as a dot, so the password never appears on screen.
4
+ * Backspace erases, Enter validates, Ctrl+C quits; escape sequences (arrow keys) are ignored.
5
+ */
6
+ export function readSecret(label, input = process.stdin, output = process.stdout) {
7
+ output.write(label);
8
+ const wasRaw = input.isRaw === true;
9
+ input.setRawMode?.(true);
10
+ input.setEncoding('utf8');
11
+ input.resume();
12
+ return new Promise((resolve) => {
13
+ let value = '';
14
+ const onData = (chunk) => {
15
+ if (chunk.startsWith('\x1b'))
16
+ return;
17
+ for (const character of chunk) {
18
+ if (character === '\r' || character === '\n') {
19
+ input.off('data', onData);
20
+ input.setRawMode?.(wasRaw);
21
+ input.pause();
22
+ output.write('\n');
23
+ resolve(value);
24
+ return;
25
+ }
26
+ if (character === '\u0003') {
27
+ input.setRawMode?.(wasRaw);
28
+ output.write('\n');
29
+ process.exit(130);
30
+ }
31
+ if (character === '\u007f' || character === '\b')
32
+ value = value.slice(0, -1);
33
+ else if (character >= ' ')
34
+ value += character;
35
+ }
36
+ output.write(`\r\x1b[K${label}${'•'.repeat(value.length)}`);
37
+ };
38
+ input.on('data', onData);
39
+ });
40
+ }
package/lib/templates.js CHANGED
@@ -101,6 +101,14 @@ export async function up(db: Knex): Promise<void> {
101
101
  table.timestamps();
102
102
  });
103
103
  }
104
+ `;
105
+ files['src/migrations/0006_add_multi_factor_authentication.ts'] = `import type { Knex } from '@navette-js/database';
106
+ import { multiFactorAuthenticationSchema } from '@navette-js/panels';
107
+
108
+ // Two-factor authentication: authenticator app secret and recovery codes (users table), and a security_keys table.
109
+ export async function up(db: Knex): Promise<void> {
110
+ await multiFactorAuthenticationSchema(db);
111
+ }
104
112
  `;
105
113
  files['src/migrations/0002_create_notifications_table.ts'] = `import type { Knex } from '@navette-js/database';
106
114
 
@@ -214,12 +222,10 @@ ${timestamps}
214
222
  `;
215
223
  // ------------------------------------------------------------------ admin panel
216
224
  files['src/panels/admin.ts'] = `// The admin panel (/admin): only users with the \`admin\` role can sign in.
217
- import { AccountWidget, discoverPages, discoverResources, discoverWidgets, fileMailer, knexAuthProvider, ${sqlite ? 'knexNotificationStore, ' : ''}Panel } from '@navette-js/panels';
225
+ import { AccountWidget, AppAuthentication, discoverPages, EditProfile, EmailAuthentication, discoverResources, discoverWidgets, fileMailer, ${sqlite ? 'knexNotificationStore, ' : ''}Panel, SecurityKeyAuthentication } from '@navette-js/panels';
218
226
  import { Color } from '@navette-js/support';
219
227
  import { db } from '../database.ts';
220
-
221
- const users = knexAuthProvider((table) => db(table));
222
- const isAdmin = async (id: string | number) => (await db('users').where('id', id).first())?.role === 'admin';
228
+ import { users } from '../users.ts';
223
229
 
224
230
  export default Panel.make()
225
231
  .id('admin')
@@ -230,21 +236,26 @@ export default Panel.make()
230
236
  .setColors({ primary: Color.Amber })
231
237
  .login()
232
238
  .passwordReset()
233
- .profile()
239
+ // Profile page in the full layout (sections, with the navigation).
240
+ .profile(EditProfile, false)
241
+ // Two-factor authentication, set up by each user on their profile: an authenticator app (with recovery codes), a code by email or a security key.
242
+ .multiFactorAuthentication([AppAuthentication.make().recoverable(), EmailAuthentication.make(), SecurityKeyAuthentication.make()])
234
243
  .mailer(fileMailer(process.env.MAIL_LOG ?? 'storage/mail.log'))
235
244
  ${sqlite ? ' .databaseNotifications(knexNotificationStore((table: string) => db(table)))\n' : ''} .globalSearchKeyBindings(['command+k', 'ctrl+k'])
236
245
  // Resources, pages and widgets are discovered from these folders: \`npx navette make:resource\` adds files there.
237
246
  .resources(await discoverResources(new URL('../admin/resources/', import.meta.url)))
238
247
  .pages(await discoverPages(new URL('../admin/pages/', import.meta.url)))
239
248
  .widgets([AccountWidget, ...(await discoverWidgets(new URL('../admin/widgets/', import.meta.url)))])
240
- .auth({
241
- ...users,
242
- attempt: async (email, password) => {
243
- const user = await users.attempt(email, password);
244
- return user && (await isAdmin(user.id)) ? user : null;
245
- },
246
- find: async (id) => ((await isAdmin(id)) ? users.find(id) : null),
247
- });
249
+ .auth(users)
250
+ // Only users with the \`admin\` role may sign in here; others are refused at login and get a 403 page.
251
+ .canAccess(async (user) => (await db('users').where('id', user.id).first())?.role === 'admin');
252
+ `;
253
+ files['src/users.ts'] = `// Users of every panel. Panels share one session (signing into one signs into the others), so they share this provider;
254
+ // each panel's \`canAccess()\` decides who may enter it.
255
+ import { knexAuthProvider } from '@navette-js/panels';
256
+ import { db } from './database.ts';
257
+
258
+ export const users = knexAuthProvider((table) => db(table));
248
259
  `;
249
260
  files['src/admin/pages/Dashboard.ts'] = `import { Dashboard as BaseDashboard } from '@navette-js/panels';
250
261
 
@@ -383,8 +394,9 @@ export class TaskStats extends StatsOverviewWidget {
383
394
  // ------------------------------------------------------------------ user panel
384
395
  if (o.userPanel) {
385
396
  files['src/panels/app.ts'] = `// The user panel (/app): anyone can sign up${teams ? ', then create or join teams' : ''}.
386
- import { AccountWidget, discoverPages, discoverResources, discoverWidgets, fileMailer, knexAuthProvider, ${teams ? 'knexTenantProvider, ' : ''}Panel } from '@navette-js/panels';
397
+ import { AccountWidget, AppAuthentication, discoverPages, EditProfile, EmailAuthentication, discoverResources, discoverWidgets, fileMailer, ${teams ? 'knexTenantProvider, ' : ''}Panel, SecurityKeyAuthentication } from '@navette-js/panels';
387
398
  import { db } from '../database.ts';
399
+ import { users } from '../users.ts';
388
400
  ${teams ? "import { EditTeamProfile, RegisterTeam } from '../app/tenancy.ts';\n" : ''}
389
401
  export default Panel.make()
390
402
  .id('app')
@@ -396,7 +408,10 @@ export default Panel.make()
396
408
  .registration()
397
409
  .passwordReset()
398
410
  .emailVerification()
399
- .profile()
411
+ // Profile page in the full layout (sections, with the navigation).
412
+ .profile(EditProfile, false)
413
+ // Two-factor authentication, set up by each user on their profile: an authenticator app (with recovery codes), a code by email or a security key.
414
+ .multiFactorAuthentication([AppAuthentication.make().recoverable(), EmailAuthentication.make(), SecurityKeyAuthentication.make()])
400
415
  .mailer(fileMailer(process.env.MAIL_LOG ?? 'storage/mail.log'))
401
416
  ${teams ? ` // Every page lives under /app/<team>; records with a \`team_id\` column only show to that team's members.
402
417
  .tenant(knexTenantProvider((table) => db(table), { label: 'Current team' }), { ownershipColumn: 'team_id' })
@@ -405,7 +420,7 @@ ${teams ? ` // Every page lives under /app/<team>; records with a \`team_id\`
405
420
  ` : ''} .resources(await discoverResources(new URL('../app/resources/', import.meta.url)))
406
421
  .pages(await discoverPages(new URL('../app/pages/', import.meta.url)))
407
422
  .widgets([AccountWidget, ...(await discoverWidgets(new URL('../app/widgets/', import.meta.url)))])
408
- .auth(knexAuthProvider((table) => db(table)));
423
+ .auth(users);
409
424
  `;
410
425
  files['src/app/pages/Dashboard.ts'] = `import { Dashboard as BaseDashboard } from '@navette-js/panels';
411
426
 
@@ -474,7 +489,8 @@ export class NoteResource extends Resource {
474
489
  files['src/server.ts'] = `// Entry point: runs the migrations, then serves every panel of src/panels.
475
490
  import express from 'express';
476
491
  import { mkdirSync } from 'node:fs';
477
- import { discoverPanels, panelRouter } from '@navette-js/panels';
492
+ import { discoverPanels, panelNotFound, panelRouter } from '@navette-js/panels';
493
+ import { views } from '@navette-js/support';
478
494
  ${o.locale === 'fr' ? "import { setLocale } from '@navette-js/support/i18n';\n" : ''}import { UploadStore, WireRuntime } from '@navette-js/wire';
479
495
  import { migrate } from './database.ts';
480
496
 
@@ -483,15 +499,20 @@ if (!key) throw new Error('APP_KEY is missing: start the server with \`npm run d
483
499
  ${o.locale === 'fr' ? "setLocale('fr');\n" : ''}mkdirSync('storage/public', { recursive: true });
484
500
 
485
501
  for (const name of await migrate()) console.log(\`Migrated \${name}\`);
502
+ // View components: <x-name> in any view renders src/components/name.ejs (\`npx navette make:component name\`).
503
+ views.components(new URL('./components/', import.meta.url));
486
504
 
487
505
  const app = express();
488
506
  // Uploaded files that are public (FileUpload with the public disk).
489
507
  app.use('/storage', express.static('storage/public', { dotfiles: 'deny', index: false }));
490
508
  // Each panel has its own runtime (its own Livewire endpoint and components).
491
- for (const panel of await discoverPanels(new URL('./panels/', import.meta.url))) {
509
+ const panels = await discoverPanels(new URL('./panels/', import.meta.url));
510
+ for (const panel of panels) {
492
511
  app.use(panelRouter(panel, new WireRuntime({ key, uploads: new UploadStore('storage/tmp', key) })));
493
512
  }
494
513
  app.get('/', (_req, res) => res.redirect('/${o.userPanel ? 'app' : 'admin'}'));
514
+ // Any other URL: the 404 page of the first panel.
515
+ if (panels[0]) app.use(panelNotFound(panels[0]));
495
516
 
496
517
  const port = Number(process.env.PORT ?? ${o.port});
497
518
  app.listen(port, '127.0.0.1', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-navette",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Create a Navette project: npm create navette@latest",
5
5
  "license": "MIT",
6
6
  "type": "module",