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.
Files changed (42) hide show
  1. package/README.md +18 -15
  2. package/index.mjs +38 -14
  3. package/package.json +1 -1
  4. package/template/laravel/README.md +6 -4
  5. package/template/laravel/config/nevela.php +17 -2
  6. package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
  7. package/template/laravel/src/Access/Access.php +168 -0
  8. package/template/laravel/src/Access/Permissions.php +271 -0
  9. package/template/laravel/src/Access/SampleUsers.php +81 -0
  10. package/template/laravel/src/Auth/Account.php +17 -1
  11. package/template/laravel/src/Console/SetupCommand.php +11 -2
  12. package/template/laravel/src/Console/UserCommand.php +151 -59
  13. package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
  14. package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
  15. package/template/laravel/src/Http/Access/RolesController.php +197 -0
  16. package/template/laravel/src/Http/Access/UsersController.php +382 -0
  17. package/template/laravel/src/Http/Auth/AccountController.php +5 -1
  18. package/template/laravel/src/Models/Role.php +73 -0
  19. package/template/laravel/src/Nevela.php +2 -1
  20. package/template/laravel/src/NevelaServiceProvider.php +29 -0
  21. package/template/web/app/api/[...path]/route.ts +5 -1
  22. package/template/web/app/dashboard/access/actions.ts +70 -0
  23. package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
  24. package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
  25. package/template/web/app/dashboard/access/roles/page.tsx +77 -0
  26. package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
  27. package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
  28. package/template/web/app/dashboard/access/users/page.tsx +158 -0
  29. package/template/web/app/dashboard/actions.ts +10 -9
  30. package/template/web/app/dashboard/import-actions.ts +6 -5
  31. package/template/web/app/dashboard/layout.tsx +5 -2
  32. package/template/web/app/dashboard/page.tsx +27 -4
  33. package/template/web/app/page.tsx +3 -9
  34. package/template/web/components/access/role-form.tsx +389 -0
  35. package/template/web/components/access/user-form.tsx +167 -0
  36. package/template/web/components/access/user-row-actions.tsx +117 -0
  37. package/template/web/components/access/users-toolbar.tsx +82 -0
  38. package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
  39. package/template/web/lib/access.ts +107 -0
  40. package/template/web/lib/dashboard.ts +51 -18
  41. package/template/web/lib/session.ts +11 -0
  42. package/template/web/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # create-nevela
2
2
 
3
- Create a new [Nevela](https://nevela-docs.vercel.app) app: a Laravel API and a Next.js dashboard, generated from one resource description.
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 every command, from this folder: php 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
- php nevela dev
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 `php artisan nevela:user`.
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
- php nevela resource Product --fields="name:string, sku:string!, price:money"
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. `php nevela` lists everything else.
46
+ That writes the Laravel code and the dashboard pages, and creates the table. `nevela` on its own lists everything else.
38
47
 
39
- ## The nevela command
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 # anywhere: the command's version, and the app's when in one
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
- With npm, put options after `--`: `npm create nevela@latest my-app -- --no-install`.
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 Which version the nevela command is, and this app when in one
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 | version | migrate | artisan <command>
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 one account, in your local database only:
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
- /** `nevela version`, from anywhere: the command's version, and the app's when in one. */
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 starter account: each separate call would start Laravel again.
695
- const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`] : [];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.4.3",
3
+ "version": "0.5.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": {
@@ -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
- php artisan nevela:resource Product --fields="name:string, sku:string!, price:money, kind:enum(stock|digital), notes:text?"
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 create command, which sets up Laravel, this package and the dashboard together:
11
+ The quickest way is the `nevela` command, which sets up Laravel, this package and the dashboard together:
13
12
 
14
13
  ```sh
15
- pnpm create nevela my-app
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
- /* The role a self-registered account starts with. Null leaves the column alone. */
78
- 'default_role' => null,
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
@@ -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
+ }