create-nevela 0.4.2 → 0.5.0
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 +2 -0
- package/index.mjs +92 -22
- package/package.json +1 -1
- package/template/laravel/config/nevela.php +17 -2
- package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
- package/template/laravel/src/Access/Access.php +168 -0
- package/template/laravel/src/Access/Permissions.php +271 -0
- package/template/laravel/src/Access/SampleUsers.php +81 -0
- package/template/laravel/src/Auth/Account.php +17 -1
- package/template/laravel/src/Console/SetupCommand.php +11 -2
- package/template/laravel/src/Console/UserCommand.php +151 -59
- package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
- package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
- package/template/laravel/src/Http/Access/RolesController.php +197 -0
- package/template/laravel/src/Http/Access/UsersController.php +382 -0
- package/template/laravel/src/Http/Auth/AccountController.php +5 -1
- package/template/laravel/src/Models/Role.php +73 -0
- package/template/laravel/src/Nevela.php +2 -1
- package/template/laravel/src/NevelaServiceProvider.php +29 -0
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/dashboard/access/actions.ts +70 -0
- package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
- package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
- package/template/web/app/dashboard/access/roles/page.tsx +77 -0
- package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
- package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
- package/template/web/app/dashboard/access/users/page.tsx +158 -0
- package/template/web/app/dashboard/actions.ts +10 -9
- package/template/web/app/dashboard/import-actions.ts +6 -5
- package/template/web/app/dashboard/layout.tsx +5 -2
- package/template/web/app/dashboard/page.tsx +27 -4
- package/template/web/components/access/role-form.tsx +389 -0
- package/template/web/components/access/user-form.tsx +167 -0
- package/template/web/components/access/user-row-actions.tsx +117 -0
- package/template/web/components/access/users-toolbar.tsx +82 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
- package/template/web/lib/access.ts +107 -0
- package/template/web/lib/dashboard.ts +51 -18
- package/template/web/lib/session.ts +11 -0
- package/template/web/package.json +1 -1
- package/tool.mjs +45 -20
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ php nevela dev
|
|
|
28
28
|
|
|
29
29
|
Open http://localhost:3000/sign-in and sign in as `admin@example.com` with the password `password`. That starter account is in your local database only; add your own with `php artisan nevela:user`.
|
|
30
30
|
|
|
31
|
+
The app starts with an administrator and ten sample users (two editors, eight users), in your local database only, so its Users and Roles screens have something to show. Leave the samples out with `--no-sample-users`.
|
|
32
|
+
|
|
31
33
|
Add your first resource, from the same folder:
|
|
32
34
|
|
|
33
35
|
```sh
|
package/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// No dependencies on purpose: this runs before anything is installed.
|
|
8
8
|
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import os from 'node:os';
|
|
@@ -29,6 +29,10 @@ const DOCS = 'https://nevela-docs.vercel.app';
|
|
|
29
29
|
* The account every new app starts with, so there is something to sign in as straight
|
|
30
30
|
* away. It is created in the app's local database, never in code or a migration, so it
|
|
31
31
|
* does not follow the app to a server.
|
|
32
|
+
*
|
|
33
|
+
* It is the app's administrator. Ten sample users are made beside it, with the same
|
|
34
|
+
* password: two editors and eight users, so the Users screen, the roles and what each
|
|
35
|
+
* of them is allowed to see have something to show on the first run.
|
|
32
36
|
*/
|
|
33
37
|
const ADMIN = { name: 'Admin', email: 'admin@example.com', password: 'password' };
|
|
34
38
|
|
|
@@ -76,7 +80,8 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
|
76
80
|
${bold('Options')}
|
|
77
81
|
--pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
|
|
78
82
|
--no-install Don't install the dashboard's dependencies.
|
|
79
|
-
--no-user Don't create the starter admin account.
|
|
83
|
+
--no-user Don't create the starter admin account, or the sample users.
|
|
84
|
+
--no-sample-users Create the admin only, without the ten sample users.
|
|
80
85
|
--fast Leave out PHPUnit, Pint and Laravel's other development packages.
|
|
81
86
|
About a third quicker. Add them later: cd apps/api && composer install
|
|
82
87
|
--no-git Don't run git init.
|
|
@@ -188,7 +193,7 @@ async function step(label, work) {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
function parseArgs(argv) {
|
|
191
|
-
const options = { name: undefined, pm: undefined, install: true, user: true, git: true, yes: false, bundledPackage: false, devTools: true };
|
|
196
|
+
const options = { name: undefined, pm: undefined, install: true, user: true, samples: true, git: true, yes: false, bundledPackage: false, devTools: true };
|
|
192
197
|
for (let i = 0; i < argv.length; i++) {
|
|
193
198
|
const arg = argv[i];
|
|
194
199
|
if (arg === '-h' || arg === '--help') {
|
|
@@ -201,6 +206,7 @@ function parseArgs(argv) {
|
|
|
201
206
|
} else if (arg === '-y' || arg === '--yes') options.yes = true;
|
|
202
207
|
else if (arg === '--no-install') options.install = false;
|
|
203
208
|
else if (arg === '--no-user') options.user = false;
|
|
209
|
+
else if (arg === '--no-sample-users') options.samples = false;
|
|
204
210
|
else if (arg === '--no-git') options.git = false;
|
|
205
211
|
else if (arg === '--no-dev-tools' || arg === '--fast') options.devTools = false;
|
|
206
212
|
else if (arg === '--bundled-package') options.bundledPackage = true;
|
|
@@ -279,7 +285,7 @@ function write(file, contents) {
|
|
|
279
285
|
fs.writeFileSync(file, contents);
|
|
280
286
|
}
|
|
281
287
|
|
|
282
|
-
function writeRootFiles(root, { name, title, pm, admin }) {
|
|
288
|
+
function writeRootFiles(root, { name, title, pm, admin, samples }) {
|
|
283
289
|
write(path.join(root, 'package.json'), `${JSON.stringify({
|
|
284
290
|
name,
|
|
285
291
|
version: '0.1.0',
|
|
@@ -313,13 +319,15 @@ To check the app at any time (versions, migrations, users, and whether the dashb
|
|
|
313
319
|
php nevela status
|
|
314
320
|
\`\`\`
|
|
315
321
|
${admin ? `
|
|
316
|
-
The app starts with
|
|
322
|
+
The app starts with an administrator, in your local database only:
|
|
317
323
|
|
|
318
324
|
| | |
|
|
319
325
|
|---|---|
|
|
320
326
|
| Email | \`${admin.email}\` |
|
|
321
327
|
| Password | \`${admin.password}\` |
|
|
322
|
-
|
|
328
|
+
${samples ? `
|
|
329
|
+
Ten sample users are there as well, with the same password: two editors and eight users, one of them switched off. They are under **Users** in the dashboard, and what each role may do is under **Roles**. Delete them before real use.
|
|
330
|
+
` : ''}` : ''}
|
|
323
331
|
Everything else runs from this folder too, with \`php nevela\`:
|
|
324
332
|
|
|
325
333
|
\`\`\`sh
|
|
@@ -369,17 +377,62 @@ async function inProject(name, args) {
|
|
|
369
377
|
process.exit(status);
|
|
370
378
|
}
|
|
371
379
|
|
|
380
|
+
/**
|
|
381
|
+
* How the copy that is running was installed, or null when it isn't the installed command.
|
|
382
|
+
* The folder it was started through is passed as well as the one its files are in: under
|
|
383
|
+
* pnpm 11 only the first says it is pnpm's.
|
|
384
|
+
*/
|
|
385
|
+
function installed() {
|
|
386
|
+
if (fromRepository) return null;
|
|
387
|
+
const startedFrom = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : here;
|
|
388
|
+
return globalInstall(here, fs.existsSync, startedFrom);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** What typing `nevela` runs now, as "x.y.z", or null when that can't be found out. */
|
|
392
|
+
function versionOnPath() {
|
|
393
|
+
const answer = spawnCommand('nevela', ['--version'], { stdio: 'pipe', encoding: 'utf8' });
|
|
394
|
+
return /^\d+\.\d+\.\d+$/m.exec(`${answer.stdout ?? ''}`)?.[0] ?? null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The launcher of the nevela command in `dir`: the file the shell runs. */
|
|
398
|
+
const launcherIn = (dir) => path.join(dir, windows ? 'nevela.cmd' : 'nevela');
|
|
399
|
+
|
|
400
|
+
/** The version of the nevela command in `dir`, as "x.y.z", or null when it won't say. */
|
|
401
|
+
function copyVersion(dir) {
|
|
402
|
+
const file = launcherIn(dir);
|
|
403
|
+
const answer = windows
|
|
404
|
+
? spawnSync(`"${file}" --version`, { shell: true, stdio: 'pipe', encoding: 'utf8' })
|
|
405
|
+
: spawnSync(file, ['--version'], { stdio: 'pipe', encoding: 'utf8' });
|
|
406
|
+
return /^\d+\.\d+\.\d+$/m.exec(`${answer.stdout ?? ''}`)?.[0] ?? null;
|
|
407
|
+
}
|
|
408
|
+
|
|
372
409
|
/**
|
|
373
410
|
* Say so when the nevela command is installed more than once. Only the first on the PATH
|
|
374
411
|
* ever runs, so an update to one of the others looks like an update that did nothing.
|
|
412
|
+
* The one to keep is the newest, which is not always the one that runs.
|
|
375
413
|
*/
|
|
376
414
|
function otherCopies() {
|
|
377
|
-
const copies = copiesOnPath();
|
|
415
|
+
const copies = copiesOnPath().map((dir) => ({ dir, version: copyVersion(dir) }));
|
|
378
416
|
if (copies.length < 2) return;
|
|
417
|
+
let keep = copies[0];
|
|
418
|
+
for (const copy of copies) {
|
|
419
|
+
if (copy.version && (!keep.version || isNewer(copy.version, keep.version))) keep = copy;
|
|
420
|
+
}
|
|
421
|
+
const width = Math.max(...copies.map((copy) => copy.dir.length));
|
|
379
422
|
console.log(`\n ${red('!')} The nevela command is installed ${copies.length} times. Typing ${bold('nevela')} runs the first of these:`);
|
|
380
|
-
for (const
|
|
381
|
-
console.log(
|
|
382
|
-
|
|
423
|
+
for (const copy of copies) console.log(` ${copy.dir.padEnd(width)} ${copy.version ? `v${copy.version}` : ''}`);
|
|
424
|
+
console.log(keep === copies[0]
|
|
425
|
+
? ` Keep that one and remove the rest, so there is one to keep up to date:`
|
|
426
|
+
: ` The one that runs is not the newest. Remove the others, and ${bold(`v${keep.version}`)} is what ${bold('nevela')} runs:`);
|
|
427
|
+
const launcher = (dir) => {
|
|
428
|
+
try {
|
|
429
|
+
return fs.readFileSync(launcherIn(dir), 'utf8').slice(0, 4000);
|
|
430
|
+
} catch {
|
|
431
|
+
return '';
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
const removals = new Set(copies.filter((copy) => copy !== keep).map((copy) => removeCommand(copy.dir, launcher(copy.dir))));
|
|
435
|
+
for (const removal of removals) console.log(` ${bold(removal)}`);
|
|
383
436
|
}
|
|
384
437
|
|
|
385
438
|
/** The version of Nevela an app has installed, read from the package, or null. */
|
|
@@ -395,7 +448,7 @@ function appVersion(project) {
|
|
|
395
448
|
/** `nevela version`, from anywhere: the command's version, and the app's when in one. */
|
|
396
449
|
function showVersion() {
|
|
397
450
|
const project = findProject(process.cwd());
|
|
398
|
-
const install =
|
|
451
|
+
const install = installed();
|
|
399
452
|
console.log(`\n ${indigo(bold('Nevela command'))} ${bold(`v${VERSION}`)}${install ? ` ${dim(`installed with ${install.manager}`)}` : ''}`);
|
|
400
453
|
if (project) {
|
|
401
454
|
const app = appVersion(project);
|
|
@@ -459,7 +512,7 @@ async function askName() {
|
|
|
459
512
|
*/
|
|
460
513
|
async function updateTool(args) {
|
|
461
514
|
const project = findProject(process.cwd());
|
|
462
|
-
const install =
|
|
515
|
+
const install = installed();
|
|
463
516
|
|
|
464
517
|
if (!install) {
|
|
465
518
|
// Run through npx or `pnpm dlx`, which fetch the latest every time, started by an
|
|
@@ -485,18 +538,29 @@ async function updateTool(args) {
|
|
|
485
538
|
if (latest === null) {
|
|
486
539
|
// Not the same as being up to date, so it isn't reported as that.
|
|
487
540
|
console.log(` ${red('!')} Couldn't reach npm to see whether there is a newer nevela than ${bold(VERSION)}. Nothing was changed.`);
|
|
488
|
-
console.log(` Try again when you are online, or run: ${install.command.join(' ')}`);
|
|
541
|
+
console.log(` Try again when you are online, or run: ${install.command().join(' ')}`);
|
|
489
542
|
process.exitCode = 1;
|
|
490
543
|
} else if (!newer) {
|
|
491
544
|
console.log(` ${green(`✔ Already on the latest version (v${VERSION}). Nothing to do.`)}`);
|
|
492
545
|
} else {
|
|
493
|
-
|
|
494
|
-
const [command, ...rest] = install.command;
|
|
546
|
+
// The version by number, not "latest": pnpm holds "latest" back for a day.
|
|
547
|
+
const [command, ...rest] = install.command(newer);
|
|
548
|
+
console.log(` Updating v${VERSION} → ${bold(`v${newer}`)} ${dim(`(${[command, ...rest].join(' ')})`)}\n`);
|
|
495
549
|
const result = spawnCommand(command, rest, { stdio: 'inherit' });
|
|
496
550
|
if (result.error || result.status !== 0) {
|
|
497
|
-
fail(`Couldn't update the nevela command.`, `Run it yourself to see why: ${
|
|
551
|
+
fail(`Couldn't update the nevela command.`, `Run it yourself to see why: ${[command, ...rest].join(' ')}`);
|
|
552
|
+
}
|
|
553
|
+
// The package manager saying so isn't the same as the command being newer: ask it.
|
|
554
|
+
const now = versionOnPath();
|
|
555
|
+
if (now === newer || now === null) {
|
|
556
|
+
console.log(`\n ${green(`✔ Updated to v${newer}.`)} ${dim('Every nevela command, and every app you create from now on, uses it.')}`);
|
|
557
|
+
} else {
|
|
558
|
+
console.log(`\n ${red('!')} ${install.manager} installed ${bold(`v${newer}`)}, but typing ${bold('nevela')} still runs ${bold(`v${now}`)}.`);
|
|
559
|
+
if (copiesOnPath().length < 2) {
|
|
560
|
+
console.log(` Open a new terminal and run ${bold('nevela version')}. If it still says v${now}, the copy that runs was installed another way.`);
|
|
561
|
+
}
|
|
562
|
+
process.exitCode = 1;
|
|
498
563
|
}
|
|
499
|
-
console.log(`\n ${green(`✔ Updated to v${newer}.`)} ${dim('Every nevela command, and every app you create from now on, uses it.')}`);
|
|
500
564
|
}
|
|
501
565
|
otherCopies();
|
|
502
566
|
if (project) {
|
|
@@ -577,7 +641,7 @@ async function main() {
|
|
|
577
641
|
// `nevela upgrade` compares against these to tell the files you changed from the ones
|
|
578
642
|
// you didn't, so an update never overwrites your work.
|
|
579
643
|
write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
|
|
580
|
-
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
|
|
644
|
+
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples });
|
|
581
645
|
const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
|
|
582
646
|
|
|
583
647
|
const tokens = await step('Creating the Laravel app', async (progress) => {
|
|
@@ -633,13 +697,16 @@ async function main() {
|
|
|
633
697
|
});
|
|
634
698
|
|
|
635
699
|
let admin = false;
|
|
700
|
+
let samples = false;
|
|
636
701
|
await step('Setting up the database', () => {
|
|
637
702
|
// One artisan process for the app key, Sanctum's migration, the generated files, the
|
|
638
|
-
// migrations and the
|
|
639
|
-
|
|
703
|
+
// migrations, the starter account and the sample users: each separate call would
|
|
704
|
+
// start Laravel again.
|
|
705
|
+
const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`, ...(options.samples ? [] : ['--no-sample-users'])] : [];
|
|
640
706
|
const setup = run('php', ['artisan', 'nevela:setup', ...account], { cwd: api, allowFailure: true });
|
|
641
707
|
if (setup.ok) {
|
|
642
708
|
admin = account.length > 0;
|
|
709
|
+
samples = admin && options.samples;
|
|
643
710
|
return;
|
|
644
711
|
}
|
|
645
712
|
// 2: the app is set up and only the starter account could not be created.
|
|
@@ -651,7 +718,7 @@ async function main() {
|
|
|
651
718
|
run('php', ['artisan', 'vendor:publish', '--tag=sanctum-migrations', '--no-interaction'], { cwd: api });
|
|
652
719
|
run('php', ['artisan', 'nevela:generate'], { cwd: api });
|
|
653
720
|
run('php', ['artisan', 'migrate', '--force', '--no-interaction'], { cwd: api });
|
|
654
|
-
if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account], { cwd: api, allowFailure: true }).ok;
|
|
721
|
+
if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account.filter((arg) => arg !== '--no-sample-users')], { cwd: api, allowFailure: true }).ok;
|
|
655
722
|
});
|
|
656
723
|
|
|
657
724
|
let installed = false;
|
|
@@ -685,7 +752,10 @@ async function main() {
|
|
|
685
752
|
if (admin) {
|
|
686
753
|
console.log(`\n Email ${bold(ADMIN.email)}`);
|
|
687
754
|
console.log(` Password ${bold(ADMIN.password)}\n`);
|
|
688
|
-
console.log(` ${dim(`That account is in this app's local database only. Add your own: ${nevela} user`)}`);
|
|
755
|
+
console.log(` ${dim(`That account is the administrator, and is in this app's local database only. Add your own: ${nevela} user`)}`);
|
|
756
|
+
if (samples) {
|
|
757
|
+
console.log(` ${dim(`Ten sample users are there too, with the same password: 2 editors and 8 users. See them under Users, and what each may do under Roles.`)}`);
|
|
758
|
+
}
|
|
689
759
|
}
|
|
690
760
|
console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
|
|
691
761
|
console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
|
package/package.json
CHANGED
|
@@ -24,6 +24,18 @@ return [
|
|
|
24
24
|
'prefix' => 'api',
|
|
25
25
|
'middleware' => ['api', 'auth:sanctum'],
|
|
26
26
|
|
|
27
|
+
/*
|
|
28
|
+
| Permissions of your own, beside the ones every resource gets (products.view,
|
|
29
|
+
| products.create, products.edit, products.delete) and the built-in users.* and roles.*.
|
|
30
|
+
| They appear on the dashboard's Roles screen, and you check one anywhere in Laravel
|
|
31
|
+
| with `$user->can('reports.view')`. Actions are any of: create, view, edit, delete.
|
|
32
|
+
|
|
|
33
|
+
| 'permissions' => [
|
|
34
|
+
| 'reports' => ['name' => 'Reports', 'actions' => ['view']],
|
|
35
|
+
| ],
|
|
36
|
+
*/
|
|
37
|
+
'permissions' => [],
|
|
38
|
+
|
|
27
39
|
/*
|
|
28
40
|
| Signing in. Every endpoint is under {prefix}/auth. Email and password is always
|
|
29
41
|
| available; the rest can be switched off here. A method that is off is refused by the
|
|
@@ -74,8 +86,11 @@ return [
|
|
|
74
86
|
*/
|
|
75
87
|
'attempts_per_address' => 300,
|
|
76
88
|
|
|
77
|
-
/*
|
|
78
|
-
|
|
89
|
+
/*
|
|
90
|
+
| The role a self-registered account starts with. USER allows nothing beyond the
|
|
91
|
+
| person's own account, which is what makes open sign-up safe. Null gives no role.
|
|
92
|
+
*/
|
|
93
|
+
'default_role' => 'USER',
|
|
79
94
|
|
|
80
95
|
/*
|
|
81
96
|
| Where the dashboard is, for the links in emails and for passkeys, which are tied
|
package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\DB;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
use Illuminate\Support\Str;
|
|
8
|
+
use Nevela\Laravel\Models\Role;
|
|
9
|
+
|
|
10
|
+
// Roles and permissions, and switching an account off.
|
|
11
|
+
//
|
|
12
|
+
// Until now every signed-in user could do everything. So that upgrading an app changes
|
|
13
|
+
// nothing about who can do what, every user it already has is made an ADMIN here. Give
|
|
14
|
+
// them narrower roles afterwards, from the dashboard's Users screen.
|
|
15
|
+
return new class extends Migration
|
|
16
|
+
{
|
|
17
|
+
public function up(): void
|
|
18
|
+
{
|
|
19
|
+
Schema::create('nevela_roles', function (Blueprint $table) {
|
|
20
|
+
$table->uuid('id')->primary();
|
|
21
|
+
$table->string('name', 80)->unique();
|
|
22
|
+
$table->string('description', 500)->nullable();
|
|
23
|
+
// A JSON list of grants: permissions, and patterns such as "products.*".
|
|
24
|
+
$table->text('grants');
|
|
25
|
+
// One the app depends on: it can be edited, and not renamed or deleted.
|
|
26
|
+
$table->boolean('is_system')->default(false);
|
|
27
|
+
$table->timestamps();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
Schema::create('nevela_role_user', function (Blueprint $table) {
|
|
31
|
+
$table->foreignUuid('role_id')->constrained('nevela_roles')->cascadeOnDelete();
|
|
32
|
+
// A string, so it holds a numeric id or a UUID, whichever the app's users have.
|
|
33
|
+
$table->string('user_id');
|
|
34
|
+
$table->primary(['role_id', 'user_id']);
|
|
35
|
+
$table->index('user_id');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
$model = config('auth.providers.users.model');
|
|
39
|
+
$user = is_string($model) && class_exists($model) ? new $model : null;
|
|
40
|
+
$users = $user?->getTable() ?? 'users';
|
|
41
|
+
$key = $user?->getKeyName() ?? 'id';
|
|
42
|
+
|
|
43
|
+
if (Schema::hasTable($users) && ! Schema::hasColumn($users, 'active')) {
|
|
44
|
+
Schema::table($users, function (Blueprint $table) {
|
|
45
|
+
// False: the account can't sign in, and is signed out everywhere.
|
|
46
|
+
$table->boolean('active')->default(true);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
$now = now();
|
|
51
|
+
$admin = null;
|
|
52
|
+
foreach (Role::defaults() as $role) {
|
|
53
|
+
$id = (string) Str::uuid();
|
|
54
|
+
DB::table('nevela_roles')->insert([
|
|
55
|
+
'id' => $id,
|
|
56
|
+
'name' => $role['name'],
|
|
57
|
+
'description' => $role['description'],
|
|
58
|
+
'grants' => json_encode($role['grants']),
|
|
59
|
+
'is_system' => true,
|
|
60
|
+
'created_at' => $now,
|
|
61
|
+
'updated_at' => $now,
|
|
62
|
+
]);
|
|
63
|
+
$admin ??= $role['name'] === Role::ADMIN ? $id : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ($admin !== null && Schema::hasTable($users)) {
|
|
67
|
+
DB::table($users)->orderBy($key)->select($key)->chunk(500, function ($rows) use ($admin, $key) {
|
|
68
|
+
DB::table('nevela_role_user')->insert($rows->map(fn ($row) => ['role_id' => $admin, 'user_id' => (string) $row->{$key}])->all());
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public function down(): void
|
|
74
|
+
{
|
|
75
|
+
// The "active" column stays: an app may have had one of its own before this ran.
|
|
76
|
+
Schema::dropIfExists('nevela_role_user');
|
|
77
|
+
Schema::dropIfExists('nevela_roles');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Access;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\DB;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
use Nevela\Laravel\Auth\Account;
|
|
8
|
+
use Nevela\Laravel\Models\Role;
|
|
9
|
+
use WeakMap;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Who holds which roles, and what those come to.
|
|
13
|
+
*
|
|
14
|
+
* Laravel's gate asks here for any ability shaped like a permission ("products.view"), so
|
|
15
|
+
* `$user->can('products.view')`, `Gate::authorize('users.edit')` and `@can` all work, and a
|
|
16
|
+
* generated policy is one line per action.
|
|
17
|
+
*/
|
|
18
|
+
final class Access
|
|
19
|
+
{
|
|
20
|
+
/** @var WeakMap<object, list<string>>|null Grants per user object, so a request asks the database once. */
|
|
21
|
+
private static ?WeakMap $grants = null;
|
|
22
|
+
|
|
23
|
+
private static bool $ready = false;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether the roles tables exist. Before an upgraded app has run its migrations they
|
|
27
|
+
* don't, and until then everyone may do everything, as they could before.
|
|
28
|
+
*/
|
|
29
|
+
public static function ready(): bool
|
|
30
|
+
{
|
|
31
|
+
// Only "yes" is remembered: the tables can appear later in the same process.
|
|
32
|
+
return self::$ready = self::$ready || Schema::hasTable('nevela_roles');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The grants a user holds, across all their roles. Patterns are kept as they are:
|
|
37
|
+
* give the result to Permissions::granted(), and don't compare strings.
|
|
38
|
+
*
|
|
39
|
+
* @return list<string>
|
|
40
|
+
*/
|
|
41
|
+
public static function grantsFor(mixed $user): array
|
|
42
|
+
{
|
|
43
|
+
if (! is_object($user)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
if (! self::ready()) {
|
|
47
|
+
return ['*'];
|
|
48
|
+
}
|
|
49
|
+
self::$grants ??= new WeakMap;
|
|
50
|
+
if (isset(self::$grants[$user])) {
|
|
51
|
+
return self::$grants[$user];
|
|
52
|
+
}
|
|
53
|
+
if (! self::active($user)) {
|
|
54
|
+
return self::$grants[$user] = [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
$grants = [];
|
|
58
|
+
foreach (self::rolesOf($user) as $role) {
|
|
59
|
+
array_push($grants, ...$role->grants);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return self::$grants[$user] = array_values(array_unique($grants));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public static function allows(mixed $user, string $permission): bool
|
|
66
|
+
{
|
|
67
|
+
return Permissions::granted(self::grantsFor($user), $permission);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An administrator: someone whose roles together come to everything. */
|
|
71
|
+
public static function isAdmin(mixed $user): bool
|
|
72
|
+
{
|
|
73
|
+
return Permissions::hasAll(self::grantsFor($user));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** False when the account has been switched off. An app with no such column has no such thing. */
|
|
77
|
+
public static function active(mixed $user): bool
|
|
78
|
+
{
|
|
79
|
+
$attributes = method_exists($user, 'getAttributes') ? $user->getAttributes() : [];
|
|
80
|
+
|
|
81
|
+
// Only an explicit "no" switches an account off: a column with nothing in it doesn't.
|
|
82
|
+
return ! isset($attributes['active']) || (bool) $attributes['active'];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @return \Illuminate\Database\Eloquent\Collection<int, Role> */
|
|
86
|
+
public static function rolesOf(mixed $user)
|
|
87
|
+
{
|
|
88
|
+
return Role::query()
|
|
89
|
+
->whereIn('id', DB::table('nevela_role_user')->where('user_id', (string) $user->getAuthIdentifier())->select('role_id'))
|
|
90
|
+
->orderBy('name')
|
|
91
|
+
->get();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Replace a user's roles.
|
|
96
|
+
*
|
|
97
|
+
* @param list<string> $roleIds
|
|
98
|
+
*/
|
|
99
|
+
public static function assign(mixed $user, array $roleIds): void
|
|
100
|
+
{
|
|
101
|
+
$id = (string) $user->getAuthIdentifier();
|
|
102
|
+
DB::transaction(function () use ($id, $roleIds) {
|
|
103
|
+
DB::table('nevela_role_user')->where('user_id', $id)->delete();
|
|
104
|
+
$rows = array_map(fn (string $role) => ['role_id' => $role, 'user_id' => $id], array_values(array_unique($roleIds)));
|
|
105
|
+
if ($rows !== []) {
|
|
106
|
+
DB::table('nevela_role_user')->insert($rows);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
self::forget();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Give a user one role by name, on top of what they have. False when there is no such role. */
|
|
113
|
+
public static function grant(mixed $user, string $roleName): bool
|
|
114
|
+
{
|
|
115
|
+
if (! self::ready()) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
$role = Role::query()->whereRaw('lower(name) = ?', [mb_strtolower($roleName)])->first();
|
|
119
|
+
if (! $role) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
DB::table('nevela_role_user')->insertOrIgnore(['role_id' => $role->id, 'user_id' => (string) $user->getAuthIdentifier()]);
|
|
123
|
+
self::forget();
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How many administrators can still sign in, leaving out `$except`.
|
|
130
|
+
* The last one can't be removed, switched off or given a lesser role.
|
|
131
|
+
*
|
|
132
|
+
* Counted here as holding one role that comes to everything (ADMIN, or one like it).
|
|
133
|
+
* Someone whose lesser roles happen to add up to everything isn't relied on to be the
|
|
134
|
+
* app's last way in.
|
|
135
|
+
*/
|
|
136
|
+
public static function otherAdmins(mixed $except = null): int
|
|
137
|
+
{
|
|
138
|
+
$model = Account::model();
|
|
139
|
+
$instance = new $model;
|
|
140
|
+
$table = $instance->getTable();
|
|
141
|
+
$key = $instance->getKeyName();
|
|
142
|
+
$hasActive = Schema::hasColumn($table, 'active');
|
|
143
|
+
|
|
144
|
+
$all =Role::query()->get()->filter(fn (Role $role) => Permissions::hasAll($role->grants))->pluck('id')->all();
|
|
145
|
+
$ids = DB::table('nevela_role_user')->whereIn('role_id', $all)->distinct()->pluck('user_id')->all();
|
|
146
|
+
if ($except !== null) {
|
|
147
|
+
$ids = array_values(array_diff($ids, [(string) $except->getAuthIdentifier()]));
|
|
148
|
+
}
|
|
149
|
+
if ($ids === []) {
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return $model::query()->whereIn($key, $ids)->when($hasActive, fn ($query) => $query->where('active', true))->count();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Forget what has been worked out, after roles or who holds them change. */
|
|
157
|
+
public static function forget(): void
|
|
158
|
+
{
|
|
159
|
+
self::$grants = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** For tests that build and drop the tables within one process. */
|
|
163
|
+
public static function reset(): void
|
|
164
|
+
{
|
|
165
|
+
self::$grants = null;
|
|
166
|
+
self::$ready = false;
|
|
167
|
+
}
|
|
168
|
+
}
|