create-nevela 0.4.3 → 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 (40) hide show
  1. package/README.md +2 -0
  2. package/index.mjs +24 -10
  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
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
@@ -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
@@ -633,7 +641,7 @@ async function main() {
633
641
  // `nevela upgrade` compares against these to tell the files you changed from the ones
634
642
  // you didn't, so an update never overwrites your work.
635
643
  write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
636
- 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 });
637
645
  const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
638
646
 
639
647
  const tokens = await step('Creating the Laravel app', async (progress) => {
@@ -689,13 +697,16 @@ async function main() {
689
697
  });
690
698
 
691
699
  let admin = false;
700
+ let samples = false;
692
701
  await step('Setting up the database', () => {
693
702
  // 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}`] : [];
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'])] : [];
696
706
  const setup = run('php', ['artisan', 'nevela:setup', ...account], { cwd: api, allowFailure: true });
697
707
  if (setup.ok) {
698
708
  admin = account.length > 0;
709
+ samples = admin && options.samples;
699
710
  return;
700
711
  }
701
712
  // 2: the app is set up and only the starter account could not be created.
@@ -707,7 +718,7 @@ async function main() {
707
718
  run('php', ['artisan', 'vendor:publish', '--tag=sanctum-migrations', '--no-interaction'], { cwd: api });
708
719
  run('php', ['artisan', 'nevela:generate'], { cwd: api });
709
720
  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;
721
+ if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account.filter((arg) => arg !== '--no-sample-users')], { cwd: api, allowFailure: true }).ok;
711
722
  });
712
723
 
713
724
  let installed = false;
@@ -741,7 +752,10 @@ async function main() {
741
752
  if (admin) {
742
753
  console.log(`\n Email ${bold(ADMIN.email)}`);
743
754
  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`)}`);
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
+ }
745
759
  }
746
760
  console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
747
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.3",
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
+ }
@@ -0,0 +1,271 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Access;
4
+
5
+ use Nevela\Laravel\Nevela;
6
+
7
+ /**
8
+ * Every permission this app understands, and the rules for matching a grant against one.
9
+ *
10
+ * A permission is `feature.action`: "products.view", "users.edit". The feature is a
11
+ * resource's table, or one of the built-in ones (users, roles), or one the app adds in
12
+ * config/nevela.php. The action is one of create, view, edit, delete.
13
+ *
14
+ * A role holds grants, and a grant may be a pattern:
15
+ *
16
+ * * everything
17
+ * products.* every action on products
18
+ * *.view viewing everything
19
+ * @resources.* every action on every resource, including ones added later
20
+ * @resources.view viewing every resource
21
+ *
22
+ * The model, the names and the matching rules are Grit's (internal/authz), so a role means
23
+ * the same thing in both.
24
+ */
25
+ final class Permissions
26
+ {
27
+ public const ACTIONS = ['create', 'view', 'edit', 'delete'];
28
+
29
+ /** @var list<array{key: string, name: string, groups: list<array{key: string, name: string, features: list<array{key: string, name: string, actions: list<string>}>}>}>|null */
30
+ private static ?array $catalog = null;
31
+
32
+ /**
33
+ * The catalog, shaped for the roles screen: modules, their groups, their features.
34
+ * Only the feature and the action appear in a permission; the rest is for reading.
35
+ *
36
+ * @return list<array{key: string, name: string, groups: list<array{key: string, name: string, features: list<array{key: string, name: string, actions: list<string>}>}>}>
37
+ */
38
+ public static function catalog(): array
39
+ {
40
+ if (self::$catalog !== null) {
41
+ return self::$catalog;
42
+ }
43
+
44
+ $taken = ['users' => true, 'roles' => true];
45
+ $modules = [[
46
+ 'key' => 'access',
47
+ 'name' => 'Access',
48
+ 'groups' => [[
49
+ 'key' => 'identity',
50
+ 'name' => 'Identity',
51
+ 'features' => [
52
+ ['key' => 'users', 'name' => 'Users', 'actions' => self::ACTIONS],
53
+ ['key' => 'roles', 'name' => 'Roles and permissions', 'actions' => self::ACTIONS],
54
+ ],
55
+ ]],
56
+ ]];
57
+
58
+ // One feature per resource, under the heading it has in the sidebar.
59
+ $groups = [];
60
+ foreach (Nevela::all() as $descriptor) {
61
+ if (isset($taken[$descriptor->table])) {
62
+ continue; // a resource on the users or roles table is governed by the built-in feature
63
+ }
64
+ $taken[$descriptor->table] = true;
65
+ $heading = trim((string) ($descriptor->group ?? '')) ?: 'Resources';
66
+ $groups[$heading] ??= ['key' => self::slug($heading), 'name' => $heading, 'features' => []];
67
+ $groups[$heading]['features'][] = ['key' => $descriptor->table, 'name' => $descriptor->pluralLabel, 'actions' => self::ACTIONS];
68
+ }
69
+ if ($groups !== []) {
70
+ $modules[] = ['key' => 'resources', 'name' => 'Resources', 'groups' => array_values($groups)];
71
+ }
72
+
73
+ // The app's own: config('nevela.permissions') = ['reports' => ['name' => 'Reports', 'actions' => ['view']]].
74
+ $custom = [];
75
+ foreach ((array) config('nevela.permissions', []) as $key => $feature) {
76
+ $key = is_string($key) ? $key : (string) ($feature['key'] ?? '');
77
+ if (! preg_match('/^[a-z][a-z0-9_]*$/', $key) || isset($taken[$key])) {
78
+ continue;
79
+ }
80
+ $taken[$key] = true;
81
+ $actions = array_values(array_intersect(self::ACTIONS, (array) ($feature['actions'] ?? self::ACTIONS)));
82
+ $custom[] = ['key' => $key, 'name' => (string) ($feature['name'] ?? ucfirst(str_replace('_', ' ', $key))), 'actions' => $actions ?: self::ACTIONS];
83
+ }
84
+ if ($custom !== []) {
85
+ $modules[] = ['key' => 'app', 'name' => 'App', 'groups' => [['key' => 'app', 'name' => 'App', 'features' => $custom]]];
86
+ }
87
+
88
+ return self::$catalog = $modules;
89
+ }
90
+
91
+ /** Forget the catalog, after the descriptors or the config change. */
92
+ public static function forget(): void
93
+ {
94
+ self::$catalog = null;
95
+ }
96
+
97
+ /**
98
+ * Every permission in the catalog, sorted.
99
+ *
100
+ * @return list<string>
101
+ */
102
+ public static function keys(): array
103
+ {
104
+ $keys = [];
105
+ foreach (self::catalog() as $module) {
106
+ foreach ($module['groups'] as $group) {
107
+ foreach ($group['features'] as $feature) {
108
+ foreach ($feature['actions'] as $action) {
109
+ $keys[] = "{$feature['key']}.{$action}";
110
+ }
111
+ }
112
+ }
113
+ }
114
+ sort($keys);
115
+
116
+ return $keys;
117
+ }
118
+
119
+ /** Whether `ability` has the shape of a permission, so the gate answers for it. */
120
+ public static function isPermission(string $ability): bool
121
+ {
122
+ return (bool) preg_match('/^[a-z][a-z0-9_]*\.(create|view|edit|delete)$/', $ability);
123
+ }
124
+
125
+ /**
126
+ * Whether any of the grants allows `key`.
127
+ *
128
+ * @param list<string> $grants
129
+ */
130
+ public static function granted(array $grants, string $key): bool
131
+ {
132
+ foreach ($grants as $grant) {
133
+ if (is_string($grant) && self::matches($grant, $key)) {
134
+ return true;
135
+ }
136
+ }
137
+
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * Whether one grant covers one permission.
143
+ *
144
+ * A pattern is checked to its end: "products.*" covers "products.view", and "*.view"
145
+ * covers "products.view" and nothing else of products. Stopping at the first "*"
146
+ * would turn "*.view" into "everything".
147
+ */
148
+ public static function matches(string $pattern, string $key): bool
149
+ {
150
+ if ($pattern === '*' || $pattern === $key) {
151
+ return true;
152
+ }
153
+ $p = explode('.', $pattern);
154
+ $k = explode('.', $key);
155
+ if (count($p) !== 2 || count($k) !== 2) {
156
+ return false;
157
+ }
158
+ [$feature, $action] = $p;
159
+
160
+ if (str_starts_with($feature, '@')) {
161
+ if (! in_array($k[0], self::featuresOf(substr($feature, 1)), true)) {
162
+ return false;
163
+ }
164
+ } elseif ($feature !== '*' && $feature !== $k[0]) {
165
+ return false;
166
+ }
167
+
168
+ return $action === '*' || $action === $k[1];
169
+ }
170
+
171
+ /**
172
+ * The permissions a set of grants comes to, patterns resolved against the catalog.
173
+ * Clients are given this, so nothing outside this class has to match a pattern.
174
+ *
175
+ * @param list<string> $grants
176
+ * @return list<string>
177
+ */
178
+ public static function expand(array $grants): array
179
+ {
180
+ return array_values(array_filter(self::keys(), fn (string $key) => self::granted($grants, $key)));
181
+ }
182
+
183
+ /**
184
+ * Whether the grants come to everything there is.
185
+ *
186
+ * @param list<string> $grants
187
+ */
188
+ public static function hasAll(array $grants): bool
189
+ {
190
+ return in_array('*', $grants, true) || count(self::expand($grants)) === count(self::keys());
191
+ }
192
+
193
+ /**
194
+ * The requested grants that `held` does not cover. A pattern is covered only when
195
+ * everything it comes to is; one that comes to nothing is not.
196
+ *
197
+ * This is the ceiling: nobody hands out more than they hold.
198
+ *
199
+ * @param list<string> $held
200
+ * @param list<string> $requested
201
+ * @return list<string>
202
+ */
203
+ public static function beyond(array $held, array $requested): array
204
+ {
205
+ if (in_array('*', $held, true)) {
206
+ return [];
207
+ }
208
+ $beyond = [];
209
+ foreach ($requested as $grant) {
210
+ if ($grant === '*') {
211
+ $beyond[] = $grant;
212
+ } elseif (str_contains($grant, '*') || str_starts_with($grant, '@')) {
213
+ $keys = self::expand([$grant]);
214
+ $covered = $keys !== [];
215
+ foreach ($keys as $key) {
216
+ $covered = $covered && self::granted($held, $key);
217
+ }
218
+ if (! $covered) {
219
+ $beyond[] = $grant;
220
+ }
221
+ } elseif (! self::granted($held, $grant)) {
222
+ $beyond[] = $grant;
223
+ }
224
+ }
225
+
226
+ return $beyond;
227
+ }
228
+
229
+ /**
230
+ * Whether a grant is one the catalog understands: "*", a permission in it, or a pattern
231
+ * that comes to at least one. A module pattern is accepted for a module with nothing in
232
+ * it yet, because its point is to cover what is added later.
233
+ */
234
+ public static function understood(string $grant): bool
235
+ {
236
+ if ($grant === '*') {
237
+ return true;
238
+ }
239
+ if (! preg_match('/^(\*|@?[a-z][a-z0-9_]*)\.(\*|create|view|edit|delete)$/', $grant)) {
240
+ return false;
241
+ }
242
+ if (str_starts_with($grant, '@')) {
243
+ return in_array(substr(explode('.', $grant)[0], 1), ['access', 'resources', 'app'], true);
244
+ }
245
+
246
+ return self::expand([$grant]) !== [];
247
+ }
248
+
249
+ /** @return list<string> */
250
+ private static function featuresOf(string $module): array
251
+ {
252
+ $features = [];
253
+ foreach (self::catalog() as $entry) {
254
+ if ($entry['key'] !== $module) {
255
+ continue;
256
+ }
257
+ foreach ($entry['groups'] as $group) {
258
+ foreach ($group['features'] as $feature) {
259
+ $features[] = $feature['key'];
260
+ }
261
+ }
262
+ }
263
+
264
+ return $features;
265
+ }
266
+
267
+ private static function slug(string $heading): string
268
+ {
269
+ return trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($heading)), '-') ?: 'resources';
270
+ }
271
+ }