create-nevela 0.2.1 → 0.4.1
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 +17 -6
- package/index.mjs +89 -17
- package/package.json +2 -1
- package/project.mjs +9 -4
- package/selfupdate.mjs +13 -3
- package/template/laravel/README.md +1 -1
- package/template/laravel/composer.json +2 -1
- package/template/laravel/config/nevela.php +66 -1
- package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
- package/template/laravel/src/Auth/Account.php +140 -0
- package/template/laravel/src/Auth/AuthMail.php +77 -0
- package/template/laravel/src/Auth/Challenges.php +135 -0
- package/template/laravel/src/Auth/Passkeys.php +206 -0
- package/template/laravel/src/Auth/Totp.php +106 -0
- package/template/laravel/src/Auth/Web.php +75 -0
- package/template/laravel/src/Console/GenerateCommand.php +1 -0
- package/template/laravel/src/Console/StatusCommand.php +2 -2
- package/template/laravel/src/Console/UpdateCommand.php +67 -9
- package/template/laravel/src/Generator/ResourceGenerator.php +63 -2
- package/template/laravel/src/Http/Auth/AccountController.php +252 -0
- package/template/laravel/src/Http/Auth/Answers.php +33 -0
- package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
- package/template/laravel/src/Http/Auth/SignInController.php +302 -0
- package/template/laravel/src/Http/TokenController.php +19 -37
- package/template/laravel/src/Http/UploadController.php +24 -12
- package/template/laravel/src/Models/Passkey.php +38 -0
- package/template/laravel/src/Models/TwoFactor.php +64 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +72 -4
- package/template/laravel/src/Support/Descriptor.php +3 -3
- package/template/laravel/src/Support/Field.php +29 -2
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/api/auth/[...action]/route.ts +196 -0
- package/template/web/app/auth-actions.ts +4 -43
- package/template/web/app/dashboard/account/layout.tsx +29 -0
- package/template/web/app/dashboard/account/page.tsx +19 -0
- package/template/web/app/dashboard/account/password/page.tsx +10 -0
- package/template/web/app/dashboard/account/security/page.tsx +19 -0
- package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
- package/template/web/app/dashboard/layout.tsx +2 -1
- package/template/web/app/forgot-password/page.tsx +22 -0
- package/template/web/app/reset-password/page.tsx +31 -0
- package/template/web/app/sign-in/page.tsx +34 -4
- package/template/web/app/sign-up/page.tsx +43 -0
- package/template/web/app/two-factor/page.tsx +34 -0
- package/template/web/app/verify-email/page.tsx +33 -0
- package/template/web/components/account/account-nav.tsx +39 -0
- package/template/web/components/account/sections.tsx +573 -0
- package/template/web/components/auth/otp-input.tsx +95 -0
- package/template/web/components/auth/password-field.tsx +116 -0
- package/template/web/components/auth/password-forms.tsx +76 -0
- package/template/web/components/auth/provider-icons.tsx +39 -0
- package/template/web/components/auth/sign-in-flow.tsx +307 -0
- package/template/web/components/auth/sign-up-form.tsx +93 -0
- package/template/web/components/auth/social-buttons.tsx +56 -0
- package/template/web/components/auth/two-factor-form.tsx +144 -0
- package/template/web/components/auth/use-resend.ts +44 -0
- package/template/web/components/auth/verify-email-form.tsx +147 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
- package/template/web/components/dashboard/user-menu.tsx +33 -1
- package/template/web/lib/auth-client.ts +279 -0
- package/template/web/lib/auth-config.ts +43 -0
- package/template/web/lib/laravel.ts +26 -1
- package/template/web/lib/password-rules.ts +167 -0
- package/template/web/lib/second-step.ts +35 -0
- package/template/web/lib/session.ts +12 -4
- package/template/web/package.json +3 -2
- package/template.mjs +1 -1
- package/tool.mjs +58 -0
- package/template/web/components/auth/sign-in-form.tsx +0 -50
package/README.md
CHANGED
|
@@ -36,21 +36,32 @@ php nevela resource Product --fields="name:string, sku:string!, price:money"
|
|
|
36
36
|
|
|
37
37
|
That writes the Laravel code and the dashboard pages, and creates the table. `php nevela` lists everything else.
|
|
38
38
|
|
|
39
|
-
##
|
|
39
|
+
## The nevela command
|
|
40
|
+
|
|
41
|
+
Install this package once and you have a `nevela` command, in every shell:
|
|
40
42
|
|
|
41
43
|
```sh
|
|
42
|
-
|
|
44
|
+
npm install -g create-nevela
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
```sh
|
|
48
|
+
nevela new my-app # anywhere: create an app
|
|
49
|
+
nevela update # anywhere: update the nevela command itself
|
|
50
|
+
nevela dev # inside an app: run the API and the dashboard
|
|
51
|
+
nevela upgrade # inside an app: bring it to the latest Nevela
|
|
52
|
+
```
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
As in Grit, `update` is for the tool and `upgrade` is for an app.
|
|
55
|
+
|
|
56
|
+
## Upgrading an app later
|
|
48
57
|
|
|
49
58
|
```sh
|
|
50
|
-
|
|
59
|
+
nevela upgrade
|
|
51
60
|
```
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
That updates the Laravel package, regenerates, and upgrades the dashboard files you have not changed. Files you changed are kept, and everything it replaces is backed up first.
|
|
63
|
+
|
|
64
|
+
Without the command installed, `npx create-nevela upgrade` does the same, in any shell and on an app of any age. So does the app's own launcher, `php nevela upgrade`. See https://nevela-docs.vercel.app/guides/updating/.
|
|
54
65
|
|
|
55
66
|
## Options
|
|
56
67
|
|
package/index.mjs
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
// No dependencies on purpose: this runs before anything is installed.
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
10
11
|
import fs from 'node:fs';
|
|
11
12
|
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
import readline from 'node:readline/promises';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { latestRelease, onPackagist } from './packagist.mjs';
|
|
16
|
-
import { COMMANDS, findProject, forward,
|
|
16
|
+
import { isNewer, latestRelease, onPackagist } from './packagist.mjs';
|
|
17
|
+
import { COMMANDS, findProject, forward, upgrade } from './project.mjs';
|
|
17
18
|
import { shellLine, spawnCommand, windows } from './shell.mjs';
|
|
18
|
-
import { newerVersion, runVersion } from './selfupdate.mjs';
|
|
19
|
+
import { latestVersion, newerVersion, runVersion } from './selfupdate.mjs';
|
|
20
|
+
import { globalInstall, INSTALL_COMMAND } from './tool.mjs';
|
|
19
21
|
import { copyLaravelPackage, copyWebTemplate, fromRepository, templateRecord } from './template.mjs';
|
|
20
22
|
|
|
21
23
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -41,12 +43,15 @@ const HELP = `
|
|
|
41
43
|
|
|
42
44
|
Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
43
45
|
|
|
44
|
-
${bold('
|
|
45
|
-
|
|
46
|
-
npm create nevela@latest <name> -- [options]
|
|
46
|
+
${bold('Get the nevela command')} (once; everything below is then this short)
|
|
47
|
+
npm install -g create-nevela
|
|
47
48
|
|
|
48
|
-
${bold('
|
|
49
|
-
nevela
|
|
49
|
+
${bold('Anywhere')}
|
|
50
|
+
nevela new <name> [options] Create an app. Without the command: pnpm create nevela <name>
|
|
51
|
+
nevela update Update the nevela command itself to the latest version
|
|
52
|
+
|
|
53
|
+
${bold('Inside an app')}
|
|
54
|
+
nevela upgrade Bring this app to the latest Nevela: the package and the dashboard
|
|
50
55
|
nevela dev Run the API and the dashboard
|
|
51
56
|
nevela status Check versions, migrations, users and the dashboard
|
|
52
57
|
nevela resource <Name> --fields="…" [--seed]
|
|
@@ -301,7 +306,7 @@ Everything else runs from this folder too, with \`php nevela\`:
|
|
|
301
306
|
\`\`\`sh
|
|
302
307
|
php nevela user # add someone who can sign in
|
|
303
308
|
php nevela status # versions, migrations, users, and what the dashboard is talking to
|
|
304
|
-
php nevela
|
|
309
|
+
php nevela upgrade # bring this app to the latest Nevela
|
|
305
310
|
php nevela # everything it can do
|
|
306
311
|
\`\`\`
|
|
307
312
|
|
|
@@ -328,25 +333,82 @@ async function inProject(name, args) {
|
|
|
328
333
|
fail(`"${name}" runs inside a Nevela app, and this folder isn't in one.`, `To create an app: pnpm create nevela my-app`);
|
|
329
334
|
}
|
|
330
335
|
if (name === 'version') console.log(`create-nevela ${VERSION}`);
|
|
331
|
-
if (name !== '
|
|
336
|
+
if (name !== 'upgrade') process.exit(forward(project, name, args));
|
|
332
337
|
|
|
333
|
-
//
|
|
338
|
+
// Upgrading is the one command that must not run on stale code itself: it is the newest
|
|
339
|
+
// installer that knows what an older app needs repaired. So even a nevela command that
|
|
340
|
+
// hasn't been updated in a while upgrades an app correctly.
|
|
334
341
|
if (!process.env.NEVELA_NO_SELF_UPDATE && !fromRepository) {
|
|
335
342
|
const newer = await newerVersion(VERSION);
|
|
336
343
|
if (newer) {
|
|
337
|
-
const status = await runVersion(newer, ['
|
|
344
|
+
const status = await runVersion(newer, ['upgrade', ...args]);
|
|
338
345
|
if (status !== null) process.exit(status);
|
|
339
346
|
}
|
|
340
347
|
}
|
|
341
|
-
console.log(`\n ${indigo(bold('Nevela'))} ${dim('
|
|
348
|
+
console.log(`\n ${indigo(bold('Nevela'))} ${dim('upgrade')}\n`);
|
|
342
349
|
const releases = await latestRelease();
|
|
343
|
-
const status = await
|
|
350
|
+
const status = await upgrade(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
|
|
344
351
|
process.exit(status);
|
|
345
352
|
}
|
|
346
353
|
|
|
354
|
+
/**
|
|
355
|
+
* `nevela update`, from anywhere: update the nevela command itself.
|
|
356
|
+
*
|
|
357
|
+
* As in Grit, updating the tool and upgrading an app are two commands. This one doesn't
|
|
358
|
+
* touch any app; it ends by saying how to upgrade the one you are in, if you are in one.
|
|
359
|
+
*
|
|
360
|
+
* It returns instead of calling process.exit(): on Windows, exiting in the same moment a
|
|
361
|
+
* fetch() has just finished trips an assertion inside Node and prints a crash.
|
|
362
|
+
*/
|
|
363
|
+
async function updateTool(args) {
|
|
364
|
+
const project = findProject(process.cwd());
|
|
365
|
+
const install = fromRepository ? null : globalInstall(here);
|
|
366
|
+
|
|
367
|
+
if (!install) {
|
|
368
|
+
// Run through npx or `pnpm dlx`, which fetch the latest every time, started by an
|
|
369
|
+
// older nevela command that fetched this one to do its work, or a copy in a project's
|
|
370
|
+
// own node_modules. There is no installed command to update. Inside an app, "update" used to mean "upgrade this app", and
|
|
371
|
+
// instructions saying so are still around, so that is what it does.
|
|
372
|
+
if (project) {
|
|
373
|
+
console.log(`\n ${dim(`"update" now updates the nevela command, and an app is brought up to date with "upgrade". Upgrading this app.`)}`);
|
|
374
|
+
await inProject('upgrade', args);
|
|
375
|
+
}
|
|
376
|
+
console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
|
|
377
|
+
console.log(` The nevela command isn't installed on this computer, so there is nothing to update.`);
|
|
378
|
+
console.log(` To install it: ${bold(INSTALL_COMMAND)}`);
|
|
379
|
+
console.log(` Then, anywhere: ${bold('nevela new my-app')} and inside an app: ${bold('nevela upgrade')}, ${bold('nevela dev')}\n`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
console.log(`\n ${indigo(bold('Nevela'))} ${dim('update')}\n`);
|
|
384
|
+
const latest = await latestVersion();
|
|
385
|
+
const newer = latest !== null && isNewer(latest, VERSION) ? latest : null;
|
|
386
|
+
if (latest === null) {
|
|
387
|
+
// Not the same as being up to date, so it isn't reported as that.
|
|
388
|
+
console.log(` ${red('!')} Couldn't reach npm to see whether there is a newer nevela than ${bold(VERSION)}. Nothing was changed.`);
|
|
389
|
+
console.log(` Try again when you are online, or run: ${install.command.join(' ')}`);
|
|
390
|
+
process.exitCode = 1;
|
|
391
|
+
} else if (!newer) {
|
|
392
|
+
console.log(` ${green('✔')} nevela ${bold(VERSION)} is the latest.`);
|
|
393
|
+
} else {
|
|
394
|
+
console.log(` Updating the nevela command ${bold(VERSION)} → ${bold(newer)} ${dim(`(${install.command.join(' ')})`)}\n`);
|
|
395
|
+
const [command, ...rest] = install.command;
|
|
396
|
+
const result = spawnCommand(command, rest, { stdio: 'inherit' });
|
|
397
|
+
if (result.error || result.status !== 0) {
|
|
398
|
+
fail(`Couldn't update the nevela command.`, `Run it yourself to see why: ${install.command.join(' ')}`);
|
|
399
|
+
}
|
|
400
|
+
console.log(`\n ${green('✔')} nevela is now ${bold(newer)}.`);
|
|
401
|
+
}
|
|
402
|
+
if (project) {
|
|
403
|
+
console.log(` ${dim('This app is not changed by that. To bring it to the latest Nevela:')} ${bold('nevela upgrade')}`);
|
|
404
|
+
}
|
|
405
|
+
console.log('');
|
|
406
|
+
}
|
|
407
|
+
|
|
347
408
|
async function main() {
|
|
348
409
|
const argv = process.argv.slice(2);
|
|
349
410
|
if (argv[0] === 'new') argv.shift();
|
|
411
|
+
else if (argv[0] === 'update') return updateTool(argv.slice(1));
|
|
350
412
|
else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
|
|
351
413
|
const options = parseArgs(argv);
|
|
352
414
|
|
|
@@ -393,13 +455,20 @@ async function main() {
|
|
|
393
455
|
// The dashboard first, so its packages can download while Composer works on Laravel.
|
|
394
456
|
// The two don't touch each other, and Composer is the long part.
|
|
395
457
|
copyWebTemplate(web, { name, title });
|
|
396
|
-
|
|
458
|
+
// Shared by the dashboard and the API, so the API can believe what the dashboard tells it
|
|
459
|
+
// about a person's browser (for the list of devices signed in to an account). Made here,
|
|
460
|
+
// per app, and never the same twice.
|
|
461
|
+
const proxySecret = crypto.randomBytes(24).toString('hex');
|
|
462
|
+
write(
|
|
463
|
+
path.join(web, '.env.local'),
|
|
464
|
+
`# Where the Laravel API is, including its prefix.\nNEVELA_API_URL=http://127.0.0.1:8000/api\n\n# The same value as in apps/api/.env. It lets the API trust this app's word on a visitor's browser.\nNEVELA_PROXY_SECRET=${proxySecret}\n`,
|
|
465
|
+
);
|
|
397
466
|
if (pm === 'pnpm') {
|
|
398
467
|
// Its own workspace file: lets sharp build, and keeps pnpm from adopting a workspace further up.
|
|
399
468
|
write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
|
|
400
469
|
}
|
|
401
470
|
// Which dashboard this app started from, with a fingerprint of every file in it.
|
|
402
|
-
// `nevela
|
|
471
|
+
// `nevela upgrade` compares against these to tell the files you changed from the ones
|
|
403
472
|
// you didn't, so an update never overwrites your work.
|
|
404
473
|
write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
|
|
405
474
|
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
|
|
@@ -449,6 +518,9 @@ async function main() {
|
|
|
449
518
|
// artisan command is slow for the same reason the index was: files read for the first time.
|
|
450
519
|
const env = path.join(api, '.env');
|
|
451
520
|
if (!fs.existsSync(env) && fs.existsSync(`${env}.example`)) fs.copyFileSync(`${env}.example`, env);
|
|
521
|
+
if (fs.existsSync(env) && !/^NEVELA_PROXY_SECRET=/m.test(fs.readFileSync(env, 'utf8'))) {
|
|
522
|
+
fs.appendFileSync(env, `\n# The same value as in apps/web/.env.local.\nNEVELA_PROXY_SECRET=${proxySecret}\n`);
|
|
523
|
+
}
|
|
452
524
|
const sqlite = path.join(api, 'database', 'database.sqlite');
|
|
453
525
|
if (/^DB_CONNECTION=sqlite\s*$/m.test(fs.readFileSync(env, 'utf8')) && !fs.existsSync(sqlite)) fs.writeFileSync(sqlite, '');
|
|
454
526
|
return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
|
|
@@ -510,7 +582,7 @@ async function main() {
|
|
|
510
582
|
console.log(` ${dim(`That account is in this app's local database only. Add your own: ${nevela} user`)}`);
|
|
511
583
|
}
|
|
512
584
|
console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
|
|
513
|
-
console.log(` Check the app: ${dim(`${nevela} status`)}
|
|
585
|
+
console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
|
|
514
586
|
if (nevela !== 'php nevela') {
|
|
515
587
|
console.log(`\n ${dim(`In this shell "php" isn't a command (your PHP is php.bat), so use ${nevela}. In PowerShell, php nevela works too.`)}`);
|
|
516
588
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-nevela",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"selfupdate.mjs",
|
|
15
15
|
"shell.mjs",
|
|
16
16
|
"project.mjs",
|
|
17
|
+
"tool.mjs",
|
|
17
18
|
"template",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
package/project.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The same commands as the app's own `php nevela`, but started from Node. That matters in
|
|
4
4
|
// shells where typing `php` doesn't work (Git Bash with Laravel Herd, where PHP is
|
|
5
|
-
// php.bat), and for `
|
|
5
|
+
// php.bat), and for `upgrade`, which has to work on an app of any age, including one from
|
|
6
6
|
// before the app had a launcher at all.
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs';
|
|
@@ -11,7 +11,7 @@ import { isNewer, satisfiesCaret } from './packagist.mjs';
|
|
|
11
11
|
import { spawnCommand } from './shell.mjs';
|
|
12
12
|
|
|
13
13
|
/** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
|
|
14
|
-
const NEVELA = ['
|
|
14
|
+
const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
|
|
15
15
|
/** These go to the artisan command of the same name. */
|
|
16
16
|
const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
|
|
17
17
|
|
|
@@ -108,8 +108,13 @@ function inApp(project, command, args) {
|
|
|
108
108
|
return result.status ?? 1;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* `nevela upgrade`: bring an app of any age to the latest Nevela.
|
|
113
|
+
*
|
|
114
|
+
* The artisan command is called by its older name, nevela:update, which every version of
|
|
115
|
+
* the package answers to (from 0.3.0 it is an alias of nevela:upgrade).
|
|
116
|
+
*/
|
|
117
|
+
export async function upgrade(project, args, { latest, say }) {
|
|
113
118
|
const check = args.includes('--check');
|
|
114
119
|
const manifestFile = path.join(project.api, 'composer.json');
|
|
115
120
|
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
package/selfupdate.mjs
CHANGED
|
@@ -14,17 +14,27 @@ import { isNewer } from './packagist.mjs';
|
|
|
14
14
|
|
|
15
15
|
const REGISTRY = 'https://registry.npmjs.org/create-nevela';
|
|
16
16
|
|
|
17
|
-
/** The newest create-nevela on npm
|
|
18
|
-
export async function
|
|
17
|
+
/** The newest create-nevela on npm, or null when npm couldn't be asked. Never throws. */
|
|
18
|
+
export async function latestVersion() {
|
|
19
19
|
try {
|
|
20
20
|
const response = await fetch(`${REGISTRY}/latest`, { signal: AbortSignal.timeout(4000) });
|
|
21
21
|
const latest = (await response.json()).version;
|
|
22
|
-
return
|
|
22
|
+
return typeof latest === 'string' && /^\d+\.\d+\.\d+$/.test(latest) ? latest : null;
|
|
23
23
|
} catch {
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The newest create-nevela on npm when it is newer than `current`, else null. Never throws.
|
|
30
|
+
* Null also when npm couldn't be asked: callers that only want to run the newest carry on
|
|
31
|
+
* with what they have.
|
|
32
|
+
*/
|
|
33
|
+
export async function newerVersion(current) {
|
|
34
|
+
const latest = await latestVersion();
|
|
35
|
+
return latest !== null && isNewer(latest, current) ? latest : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
/**
|
|
29
39
|
* Unpack a .tar (already gunzipped) into `dir`. Enough of the format for an npm tarball:
|
|
30
40
|
* regular files and directories, with the long-name extensions npm uses.
|
|
@@ -43,7 +43,7 @@ Needs PHP 8.2 or newer and Laravel 11, 12 or 13.
|
|
|
43
43
|
| `nevela:generate` | Regenerate after changing a descriptor. Your own code is kept. |
|
|
44
44
|
| `nevela:seed` | Fill a resource with plausible records, and print how long it took. |
|
|
45
45
|
| `nevela:user` | Create someone who can sign in. |
|
|
46
|
-
| `nevela:
|
|
46
|
+
| `nevela:upgrade` | Bring the app to the latest Nevela: the package, the generated code and the dashboard. `nevela:update` is its older name and still works. |
|
|
47
47
|
|
|
48
48
|
Full documentation: https://nevela-docs.vercel.app
|
|
49
49
|
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"illuminate/support": "^11.0|^12.0|^13.0",
|
|
31
31
|
"illuminate/database": "^11.17|^12.0|^13.0",
|
|
32
32
|
"illuminate/http": "^11.0|^12.0|^13.0",
|
|
33
|
-
"illuminate/console": "^11.0|^12.0|^13.0"
|
|
33
|
+
"illuminate/console": "^11.0|^12.0|^13.0",
|
|
34
|
+
"lbuchs/webauthn": "^2.2"
|
|
34
35
|
},
|
|
35
36
|
"require-dev": {
|
|
36
37
|
"phpunit/phpunit": "^11.0"
|
|
@@ -24,10 +24,75 @@ return [
|
|
|
24
24
|
'prefix' => 'api',
|
|
25
25
|
'middleware' => ['api', 'auth:sanctum'],
|
|
26
26
|
|
|
27
|
-
/*
|
|
27
|
+
/*
|
|
28
|
+
| Signing in. Every endpoint is under {prefix}/auth. Email and password is always
|
|
29
|
+
| available; the rest can be switched off here. A method that is off is refused by the
|
|
30
|
+
| API, not just hidden, and `php artisan nevela:generate` writes these choices to the
|
|
31
|
+
| dashboard (apps/web/lib/auth-config.ts) so its screens agree.
|
|
32
|
+
*/
|
|
28
33
|
'auth' => [
|
|
29
34
|
'enabled' => true,
|
|
30
35
|
'token_name' => 'nevela-web',
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
| Let anyone create their own account at /sign-up. Off by default, and accounts come
|
|
39
|
+
| from `php nevela user`: a generated policy lets every signed-in user do everything
|
|
40
|
+
| until you tighten it, so open sign-up on an untouched app would hand the dashboard
|
|
41
|
+
| to whoever finds it. Tighten app/Policies first, then set NEVELA_REGISTRATION=true.
|
|
42
|
+
*/
|
|
43
|
+
'registration' => (bool) env('NEVELA_REGISTRATION', false),
|
|
44
|
+
|
|
45
|
+
/* A sign-in link by email. */
|
|
46
|
+
'magic_link' => true,
|
|
47
|
+
|
|
48
|
+
/* A 6-digit sign-in code by email. */
|
|
49
|
+
'email_code' => true,
|
|
50
|
+
|
|
51
|
+
/* Face ID, Touch ID, Windows Hello or a security key. */
|
|
52
|
+
'passkeys' => true,
|
|
53
|
+
|
|
54
|
+
'two_factor' => [
|
|
55
|
+
/* Codes from an authenticator app (TOTP), with backup codes. */
|
|
56
|
+
'authenticator' => true,
|
|
57
|
+
/* Codes by email as the second step. */
|
|
58
|
+
'email' => true,
|
|
59
|
+
],
|
|
60
|
+
|
|
61
|
+
/* Refuse password sign-in until the email address is verified. */
|
|
62
|
+
'require_email_verification' => false,
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
| Check new passwords against Have I Been Pwned's breach list. Only the first five
|
|
66
|
+
| characters of the password's hash are sent, and an outage lets the password through.
|
|
67
|
+
*/
|
|
68
|
+
'check_breached_passwords' => true,
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
| Sign-in attempts are limited to ten a minute per account. This is the limit per
|
|
72
|
+
| address on top of that. It is high because the dashboard's server makes the calls:
|
|
73
|
+
| to Laravel, everyone using the dashboard comes from that one address.
|
|
74
|
+
*/
|
|
75
|
+
'attempts_per_address' => 300,
|
|
76
|
+
|
|
77
|
+
/* The role a self-registered account starts with. Null leaves the column alone. */
|
|
78
|
+
'default_role' => null,
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
| Where the dashboard is, for the links in emails and for passkeys, which are tied
|
|
82
|
+
| to its address. In development any localhost port is accepted as well, because
|
|
83
|
+
| the dashboard moves to another port when 3000 is taken.
|
|
84
|
+
*/
|
|
85
|
+
'web_url' => env('NEVELA_WEB_URL', 'http://localhost:3000'),
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
| A secret the dashboard's server and Laravel share, so Laravel can believe what the
|
|
89
|
+
| dashboard says about a person's browser and address (for the list of devices).
|
|
90
|
+
| Put the same value in the dashboard's .env.local. New apps get one when created.
|
|
91
|
+
*/
|
|
92
|
+
'proxy_secret' => env('NEVELA_PROXY_SECRET'),
|
|
93
|
+
|
|
94
|
+
/* The name an authenticator app and a passkey prompt show. Null: the app's name. */
|
|
95
|
+
'issuer' => null,
|
|
31
96
|
],
|
|
32
97
|
|
|
33
98
|
/* List defaults, matching Flare's client. */
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\Schema;
|
|
6
|
+
|
|
7
|
+
// What two-factor sign-in, passkeys, the profile picture and the list of devices need.
|
|
8
|
+
// Columns are only added where they are missing, so an app that already has an "avatar"
|
|
9
|
+
// column, say, keeps its own.
|
|
10
|
+
return new class extends Migration
|
|
11
|
+
{
|
|
12
|
+
public function up(): void
|
|
13
|
+
{
|
|
14
|
+
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'avatar')) {
|
|
15
|
+
Schema::table('users', function (Blueprint $table) {
|
|
16
|
+
// The key of an uploaded image (see nevela_uploads), not a URL.
|
|
17
|
+
$table->string('avatar', 512)->nullable();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A device is a Sanctum token; these say which browser and where it signed in from.
|
|
22
|
+
if (Schema::hasTable('personal_access_tokens')) {
|
|
23
|
+
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
|
24
|
+
if (! Schema::hasColumn('personal_access_tokens', 'ip_address')) {
|
|
25
|
+
$table->string('ip_address', 45)->nullable();
|
|
26
|
+
}
|
|
27
|
+
if (! Schema::hasColumn('personal_access_tokens', 'user_agent')) {
|
|
28
|
+
$table->string('user_agent', 512)->nullable();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Schema::create('nevela_two_factor', function (Blueprint $table) {
|
|
34
|
+
$table->uuid('id')->primary();
|
|
35
|
+
$table->string('user_id')->unique();
|
|
36
|
+
// The authenticator app's secret, encrypted. Null when only email codes are used.
|
|
37
|
+
$table->text('secret')->nullable();
|
|
38
|
+
$table->timestamp('totp_confirmed_at')->nullable();
|
|
39
|
+
// The 30-second step of the last code accepted, so the same code isn't taken twice.
|
|
40
|
+
$table->unsignedBigInteger('totp_last_step')->nullable();
|
|
41
|
+
// Hashes of the backup codes not used yet, encrypted.
|
|
42
|
+
$table->text('backup_codes')->nullable();
|
|
43
|
+
$table->timestamp('enabled_at')->nullable();
|
|
44
|
+
$table->timestamps();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
Schema::create('nevela_passkeys', function (Blueprint $table) {
|
|
48
|
+
$table->uuid('id')->primary();
|
|
49
|
+
$table->string('user_id')->index();
|
|
50
|
+
$table->string('name')->nullable();
|
|
51
|
+
$table->string('credential_id', 1024)->unique();
|
|
52
|
+
$table->text('public_key');
|
|
53
|
+
$table->unsignedBigInteger('counter')->default(0);
|
|
54
|
+
$table->json('transports')->nullable();
|
|
55
|
+
$table->string('aaguid')->nullable();
|
|
56
|
+
$table->timestamp('last_used_at')->nullable();
|
|
57
|
+
$table->timestamps();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public function down(): void
|
|
62
|
+
{
|
|
63
|
+
Schema::dropIfExists('nevela_passkeys');
|
|
64
|
+
Schema::dropIfExists('nevela_two_factor');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\Request;
|
|
6
|
+
use Illuminate\Support\Facades\DB;
|
|
7
|
+
use Illuminate\Support\Facades\Schema;
|
|
8
|
+
use Illuminate\Validation\Rules\Password;
|
|
9
|
+
use Nevela\Laravel\Media\Uploads;
|
|
10
|
+
use Nevela\Laravel\Models\TwoFactor;
|
|
11
|
+
use RuntimeException;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The signed-in person, as the API describes them, and the tokens that are their devices.
|
|
15
|
+
*/
|
|
16
|
+
final class Account
|
|
17
|
+
{
|
|
18
|
+
/** The app's user model. */
|
|
19
|
+
public static function model(): string
|
|
20
|
+
{
|
|
21
|
+
return (string) config('auth.providers.users.model');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public static function findByEmail(string $email): mixed
|
|
25
|
+
{
|
|
26
|
+
return self::model()::query()->whereRaw('lower(email) = ?', [mb_strtolower(trim($email))])->first();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a client needs to know about a user.
|
|
31
|
+
*
|
|
32
|
+
* @return array<string, mixed>
|
|
33
|
+
*/
|
|
34
|
+
public static function describe(mixed $user): array
|
|
35
|
+
{
|
|
36
|
+
$avatar = $user->avatar ?? null;
|
|
37
|
+
$file = is_string($avatar) && $avatar !== '' ? Uploads::ref($avatar) : null;
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
'id' => (string) $user->getAuthIdentifier(),
|
|
41
|
+
'name' => $user->name ?? null,
|
|
42
|
+
'email' => $user->email ?? null,
|
|
43
|
+
'role' => $user->role ?? null,
|
|
44
|
+
'emailVerified' => ($user->email_verified_at ?? null) !== null,
|
|
45
|
+
'twoFactorEnabled' => self::twoFactor($user)?->enabled_at !== null,
|
|
46
|
+
// The key is what is stored; "image" is the small rendition, ready for an <img>.
|
|
47
|
+
'avatar' => $file ? $avatar : null,
|
|
48
|
+
'avatarFile' => $file,
|
|
49
|
+
'image' => $file ? ($file['renditions']->thumb['url'] ?? $file['url']) : null,
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public static function twoFactor(mixed $user): ?TwoFactor
|
|
54
|
+
{
|
|
55
|
+
return TwoFactor::query()->where('user_id', (string) $user->getAuthIdentifier())->first();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sign someone in: a new Sanctum token, remembered with the browser it was made for.
|
|
60
|
+
*
|
|
61
|
+
* @return array{token: string, user: array<string, mixed>}
|
|
62
|
+
*/
|
|
63
|
+
public static function signIn(mixed $user, Request $request): array
|
|
64
|
+
{
|
|
65
|
+
if (! method_exists($user, 'createToken')) {
|
|
66
|
+
throw new RuntimeException('Add Laravel\Sanctum\HasApiTokens to '.self::model().' to issue Nevela tokens.');
|
|
67
|
+
}
|
|
68
|
+
$name = (string) ($request->input('deviceName') ?: config('nevela.auth.token_name', 'nevela-web'));
|
|
69
|
+
$token = $user->createToken(mb_substr($name, 0, 255));
|
|
70
|
+
self::remember($token->accessToken->getKey(), $request);
|
|
71
|
+
|
|
72
|
+
return ['token' => $token->plainTextToken, 'user' => self::describe($user)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The browser's address and name. The dashboard's server makes the call to Laravel,
|
|
77
|
+
* so it passes these on from the person's own request; a direct API client is itself.
|
|
78
|
+
*/
|
|
79
|
+
private static function remember(mixed $tokenId, Request $request): void
|
|
80
|
+
{
|
|
81
|
+
if (! Schema::hasColumn('personal_access_tokens', 'user_agent')) {
|
|
82
|
+
return; // the migration hasn't been run yet; signing in still works
|
|
83
|
+
}
|
|
84
|
+
$dashboard = self::fromDashboard($request);
|
|
85
|
+
$ip = $dashboard ? (string) $request->header('X-Nevela-Ip', '') : '';
|
|
86
|
+
$agent = $dashboard ? (string) $request->header('X-Nevela-User-Agent', '') : '';
|
|
87
|
+
DB::table('personal_access_tokens')->where('id', $tokenId)->update([
|
|
88
|
+
'ip_address' => filter_var($ip, FILTER_VALIDATE_IP) ? $ip : $request->ip(),
|
|
89
|
+
'user_agent' => mb_substr($agent !== '' ? $agent : (string) $request->userAgent(), 0, 512) ?: null,
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a request comes from the dashboard's server, and so may speak for a browser.
|
|
95
|
+
*
|
|
96
|
+
* Anyone can call the API directly and send the same headers, so they are believed only
|
|
97
|
+
* with the secret the dashboard and Laravel share (NEVELA_PROXY_SECRET in both). Without
|
|
98
|
+
* one configured they are believed from this machine while the app is in development,
|
|
99
|
+
* and otherwise not at all: a device then shows the address the request really came from.
|
|
100
|
+
*/
|
|
101
|
+
public static function fromDashboard(Request $request): bool
|
|
102
|
+
{
|
|
103
|
+
$secret = config('nevela.auth.proxy_secret');
|
|
104
|
+
if (is_string($secret) && $secret !== '') {
|
|
105
|
+
return hash_equals($secret, (string) $request->header('X-Nevela-Proxy-Secret', ''));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return app()->environment('local') && in_array($request->ip(), ['127.0.0.1', '::1'], true);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The rules a new password has to meet. Length is what is required; the dashboard
|
|
113
|
+
* shows the rest as advice. A password found in a breach is refused, because it is
|
|
114
|
+
* in the lists attackers try first however strong it looks.
|
|
115
|
+
*
|
|
116
|
+
* @return list<mixed>
|
|
117
|
+
*/
|
|
118
|
+
public static function passwordRules(): array
|
|
119
|
+
{
|
|
120
|
+
$rule = Password::min(8);
|
|
121
|
+
if (config('nevela.auth.check_breached_passwords', true) && ! app()->runningUnitTests()) {
|
|
122
|
+
$rule = $rule->uncompromised();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return ['required', 'string', 'max:128', $rule];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Sign every device out, except the one asking when `$except` is given. */
|
|
129
|
+
public static function revokeTokens(mixed $user, mixed $except = null): int
|
|
130
|
+
{
|
|
131
|
+
$query = DB::table('personal_access_tokens')
|
|
132
|
+
->where('tokenable_type', $user->getMorphClass())
|
|
133
|
+
->where('tokenable_id', $user->getAuthIdentifier());
|
|
134
|
+
if ($except !== null) {
|
|
135
|
+
$query->where('id', '!=', $except);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return $query->delete();
|
|
139
|
+
}
|
|
140
|
+
}
|