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.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/index.mjs +92 -22
  3. package/package.json +1 -1
  4. package/template/laravel/config/nevela.php +17 -2
  5. package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
  6. package/template/laravel/src/Access/Access.php +168 -0
  7. package/template/laravel/src/Access/Permissions.php +271 -0
  8. package/template/laravel/src/Access/SampleUsers.php +81 -0
  9. package/template/laravel/src/Auth/Account.php +17 -1
  10. package/template/laravel/src/Console/SetupCommand.php +11 -2
  11. package/template/laravel/src/Console/UserCommand.php +151 -59
  12. package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
  13. package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
  14. package/template/laravel/src/Http/Access/RolesController.php +197 -0
  15. package/template/laravel/src/Http/Access/UsersController.php +382 -0
  16. package/template/laravel/src/Http/Auth/AccountController.php +5 -1
  17. package/template/laravel/src/Models/Role.php +73 -0
  18. package/template/laravel/src/Nevela.php +2 -1
  19. package/template/laravel/src/NevelaServiceProvider.php +29 -0
  20. package/template/web/app/api/[...path]/route.ts +5 -1
  21. package/template/web/app/dashboard/access/actions.ts +70 -0
  22. package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
  23. package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
  24. package/template/web/app/dashboard/access/roles/page.tsx +77 -0
  25. package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
  26. package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
  27. package/template/web/app/dashboard/access/users/page.tsx +158 -0
  28. package/template/web/app/dashboard/actions.ts +10 -9
  29. package/template/web/app/dashboard/import-actions.ts +6 -5
  30. package/template/web/app/dashboard/layout.tsx +5 -2
  31. package/template/web/app/dashboard/page.tsx +27 -4
  32. package/template/web/components/access/role-form.tsx +389 -0
  33. package/template/web/components/access/user-form.tsx +167 -0
  34. package/template/web/components/access/user-row-actions.tsx +117 -0
  35. package/template/web/components/access/users-toolbar.tsx +82 -0
  36. package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
  37. package/template/web/lib/access.ts +107 -0
  38. package/template/web/lib/dashboard.ts +51 -18
  39. package/template/web/lib/session.ts +11 -0
  40. package/template/web/package.json +1 -1
  41. 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 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
@@ -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 dir of copies) console.log(` ${dir}`);
381
- console.log(` Keep that one and remove the rest, so there is one to keep up to date:`);
382
- for (const dir of copies.slice(1)) console.log(` ${bold(removeCommand(dir))}`);
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 = fromRepository ? null : globalInstall(here);
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 = fromRepository ? null : globalInstall(here);
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
- console.log(` Updating v${VERSION} → ${bold(`v${newer}`)} ${dim(`(${install.command.join(' ')})`)}\n`);
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: ${install.command.join(' ')}`);
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 starter account: each separate call would start Laravel again.
639
- const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`] : [];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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": {
@@ -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
+ }