create-nevela 0.4.3 → 0.5.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 +18 -15
- package/index.mjs +38 -14
- package/package.json +1 -1
- package/template/laravel/README.md +6 -4
- 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/app/page.tsx +3 -9
- 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/README.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# create-nevela
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `nevela` command. It creates a new [Nevela](https://nevela-docs.vercel.app) app, a Laravel API and a Next.js dashboard generated from one resource description, and runs everything inside one.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g create-nevela # once
|
|
7
|
+
nevela new my-app
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Or without installing anything:
|
|
4
11
|
|
|
5
12
|
```sh
|
|
6
13
|
pnpm create nevela my-app
|
|
@@ -16,39 +23,35 @@ You need PHP 8.3 or newer, Composer, and Node.js 20 or newer.
|
|
|
16
23
|
my-app/
|
|
17
24
|
├─ apps/api/ a Laravel app with Sanctum and Nevela installed
|
|
18
25
|
├─ apps/web/ the Next.js dashboard, pointed at the API
|
|
19
|
-
└─ nevela
|
|
26
|
+
└─ nevela the app's own launcher: php nevela does what nevela does
|
|
20
27
|
```
|
|
21
28
|
|
|
22
29
|
Then:
|
|
23
30
|
|
|
24
31
|
```sh
|
|
25
32
|
cd my-app
|
|
26
|
-
|
|
33
|
+
nevela dev
|
|
27
34
|
```
|
|
28
35
|
|
|
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 `
|
|
36
|
+
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 `nevela user`.
|
|
37
|
+
|
|
38
|
+
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`.
|
|
30
39
|
|
|
31
40
|
Add your first resource, from the same folder:
|
|
32
41
|
|
|
33
42
|
```sh
|
|
34
|
-
|
|
43
|
+
nevela resource Product --fields="name:string, sku:string!, price:money"
|
|
35
44
|
```
|
|
36
45
|
|
|
37
|
-
That writes the Laravel code and the dashboard pages, and creates the table. `
|
|
46
|
+
That writes the Laravel code and the dashboard pages, and creates the table. `nevela` on its own lists everything else.
|
|
38
47
|
|
|
39
|
-
##
|
|
40
|
-
|
|
41
|
-
Install this package once and you have a `nevela` command, in every shell:
|
|
42
|
-
|
|
43
|
-
```sh
|
|
44
|
-
npm install -g create-nevela
|
|
45
|
-
```
|
|
48
|
+
## Every command
|
|
46
49
|
|
|
47
50
|
```sh
|
|
48
51
|
nevela # anywhere: everything it can do
|
|
49
52
|
nevela new my-app # anywhere: create an app (leave the name off and it asks)
|
|
50
53
|
nevela update # anywhere: update the nevela command itself
|
|
51
|
-
nevela version
|
|
54
|
+
nevela --version # anywhere: the command's version, and the app's when in one
|
|
52
55
|
nevela dev # inside an app: run the API and the dashboard
|
|
53
56
|
nevela upgrade # inside an app: bring it to the latest Nevela
|
|
54
57
|
```
|
|
@@ -77,7 +80,7 @@ Without the command installed, `npx create-nevela upgrade` does the same, in any
|
|
|
77
80
|
| `--bundled-package` | Use the copy of `nevela/laravel` inside this installer instead of the release on Packagist. |
|
|
78
81
|
| `-y`, `--yes` | Ask nothing. Only the app name is ever asked for. |
|
|
79
82
|
|
|
80
|
-
|
|
83
|
+
Through npm's `create`, put options after `--`: `npm create nevela@latest my-app -- --no-install`.
|
|
81
84
|
|
|
82
85
|
## How it installs the Laravel package
|
|
83
86
|
|
package/index.mjs
CHANGED
|
@@ -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
|
|
|
@@ -64,19 +68,20 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
|
64
68
|
nevela new [name] [options] Create an app. It asks for the name if you leave it off.
|
|
65
69
|
Without the command: pnpm create nevela <name>
|
|
66
70
|
nevela update Update the nevela command itself to the latest version
|
|
67
|
-
nevela version
|
|
71
|
+
nevela --version Which version the nevela command is, and this app when in one
|
|
68
72
|
|
|
69
73
|
${bold('Inside an app')}
|
|
70
74
|
nevela upgrade Bring this app to the latest Nevela: the package and the dashboard
|
|
71
75
|
nevela dev Run the API and the dashboard
|
|
72
76
|
nevela status Check versions, migrations, users and the dashboard
|
|
73
77
|
nevela resource <Name> --fields="…" [--seed]
|
|
74
|
-
nevela seed | user | generate |
|
|
78
|
+
nevela seed | user | generate | migrate | artisan <command>
|
|
75
79
|
|
|
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.
|
|
@@ -84,7 +89,7 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
|
84
89
|
instead of the release on Packagist.
|
|
85
90
|
-y, --yes Ask nothing. (Only the app name is ever asked for.)
|
|
86
91
|
-h, --help Show this.
|
|
87
|
-
-v, --version Show the version.
|
|
92
|
+
-v, --version Show the version. Piped or captured, the number alone.
|
|
88
93
|
|
|
89
94
|
${bold('Needs')} PHP 8.3+, Composer, Node.js 20+
|
|
90
95
|
${bold('Docs')} ${DOCS}
|
|
@@ -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
|
|
@@ -437,7 +445,10 @@ function appVersion(project) {
|
|
|
437
445
|
}
|
|
438
446
|
}
|
|
439
447
|
|
|
440
|
-
/**
|
|
448
|
+
/**
|
|
449
|
+
* `nevela --version`, from anywhere: the command's version, and the app's when in one.
|
|
450
|
+
* `nevela version` is the same thing by its older name.
|
|
451
|
+
*/
|
|
441
452
|
function showVersion() {
|
|
442
453
|
const project = findProject(process.cwd());
|
|
443
454
|
const install = installed();
|
|
@@ -566,6 +577,13 @@ async function main() {
|
|
|
566
577
|
if (argv[0] === 'new') argv.shift();
|
|
567
578
|
else if (argv[0] === 'update') return updateTool(argv.slice(1));
|
|
568
579
|
else if (argv[0] === 'version') return showVersion();
|
|
580
|
+
else if ((argv[0] === '--version' || argv[0] === '-v') && argv.length === 1) {
|
|
581
|
+
// To a person, the whole answer. To a script (and to `nevela update`, which asks
|
|
582
|
+
// this of the installed command to check its work), the number and nothing else.
|
|
583
|
+
if (process.stdout.isTTY) return showVersion();
|
|
584
|
+
console.log(VERSION);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
569
587
|
else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
|
|
570
588
|
else if (argv[0] === 'help' || (startedAsNevela && argv.length === 0)) {
|
|
571
589
|
// `nevela` on its own says what it can do. `pnpm create nevela` on its own starts an
|
|
@@ -633,7 +651,7 @@ async function main() {
|
|
|
633
651
|
// `nevela upgrade` compares against these to tell the files you changed from the ones
|
|
634
652
|
// you didn't, so an update never overwrites your work.
|
|
635
653
|
write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
|
|
636
|
-
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
|
|
654
|
+
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples });
|
|
637
655
|
const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
|
|
638
656
|
|
|
639
657
|
const tokens = await step('Creating the Laravel app', async (progress) => {
|
|
@@ -689,13 +707,16 @@ async function main() {
|
|
|
689
707
|
});
|
|
690
708
|
|
|
691
709
|
let admin = false;
|
|
710
|
+
let samples = false;
|
|
692
711
|
await step('Setting up the database', () => {
|
|
693
712
|
// One artisan process for the app key, Sanctum's migration, the generated files, the
|
|
694
|
-
// migrations and the
|
|
695
|
-
|
|
713
|
+
// migrations, the starter account and the sample users: each separate call would
|
|
714
|
+
// start Laravel again.
|
|
715
|
+
const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`, ...(options.samples ? [] : ['--no-sample-users'])] : [];
|
|
696
716
|
const setup = run('php', ['artisan', 'nevela:setup', ...account], { cwd: api, allowFailure: true });
|
|
697
717
|
if (setup.ok) {
|
|
698
718
|
admin = account.length > 0;
|
|
719
|
+
samples = admin && options.samples;
|
|
699
720
|
return;
|
|
700
721
|
}
|
|
701
722
|
// 2: the app is set up and only the starter account could not be created.
|
|
@@ -707,7 +728,7 @@ async function main() {
|
|
|
707
728
|
run('php', ['artisan', 'vendor:publish', '--tag=sanctum-migrations', '--no-interaction'], { cwd: api });
|
|
708
729
|
run('php', ['artisan', 'nevela:generate'], { cwd: api });
|
|
709
730
|
run('php', ['artisan', 'migrate', '--force', '--no-interaction'], { cwd: api });
|
|
710
|
-
if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account], { cwd: api, allowFailure: true }).ok;
|
|
731
|
+
if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account.filter((arg) => arg !== '--no-sample-users')], { cwd: api, allowFailure: true }).ok;
|
|
711
732
|
});
|
|
712
733
|
|
|
713
734
|
let installed = false;
|
|
@@ -741,7 +762,10 @@ async function main() {
|
|
|
741
762
|
if (admin) {
|
|
742
763
|
console.log(`\n Email ${bold(ADMIN.email)}`);
|
|
743
764
|
console.log(` Password ${bold(ADMIN.password)}\n`);
|
|
744
|
-
console.log(` ${dim(`That account is in this app's local database only. Add your own: ${nevela} user`)}`);
|
|
765
|
+
console.log(` ${dim(`That account is the administrator, and is in this app's local database only. Add your own: ${nevela} user`)}`);
|
|
766
|
+
if (samples) {
|
|
767
|
+
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.`)}`);
|
|
768
|
+
}
|
|
745
769
|
}
|
|
746
770
|
console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
|
|
747
771
|
console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
|
package/package.json
CHANGED
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
The Laravel side of [Nevela](https://nevela-docs.vercel.app): describe a resource once and get its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
|
|
7
|
-
php artisan migrate
|
|
6
|
+
nevela resource Product --fields="name:string, sku:string!, price:money, kind:enum(stock|digital), notes:text?"
|
|
8
7
|
```
|
|
9
8
|
|
|
10
9
|
## Start a new app
|
|
11
10
|
|
|
12
|
-
The quickest way is the
|
|
11
|
+
The quickest way is the `nevela` command, which sets up Laravel, this package and the dashboard together:
|
|
13
12
|
|
|
14
13
|
```sh
|
|
15
|
-
|
|
14
|
+
npm install -g create-nevela # once
|
|
15
|
+
nevela new my-app
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
Without installing it, `pnpm create nevela my-app` does the same.
|
|
19
|
+
|
|
18
20
|
## Add it to an existing Laravel app
|
|
19
21
|
|
|
20
22
|
```sh
|
|
@@ -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
|
+
}
|