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
@@ -0,0 +1,81 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Access;
4
+
5
+ use Illuminate\Support\Facades\Hash;
6
+ use Illuminate\Support\Facades\Schema;
7
+ use Nevela\Laravel\Auth\Account;
8
+ use Nevela\Laravel\Models\Role;
9
+
10
+ /**
11
+ * Ten people for a new app, so its Users screen, its roles and its permissions have
12
+ * something to show from the first run: two editors and eight users, one switched off.
13
+ *
14
+ * Like the starter admin they are made in the app's local database by the installer,
15
+ * never by a migration, so they don't follow the app to a server. They all share one
16
+ * password, which is printed when they are made, so they are never made in production.
17
+ */
18
+ final class SampleUsers
19
+ {
20
+ /** Name, role, and whether the account is switched on. The addresses are at example.com, which nobody owns. */
21
+ public const PEOPLE = [
22
+ ['Amara Okafor', Role::EDITOR, true],
23
+ ['Daniel Kim', Role::EDITOR, true],
24
+ ['Sofia Martinez', Role::USER, true],
25
+ ['James Mwangi', Role::USER, true],
26
+ ['Priya Nair', Role::USER, true],
27
+ ['Lucas Silva', Role::USER, true],
28
+ ['Hannah Schmidt', Role::USER, true],
29
+ ['Omar Haddad', Role::USER, true],
30
+ ['Grace Nakato', Role::USER, true],
31
+ ['Noah Williams', Role::USER, false],
32
+ ];
33
+
34
+ /** Ten accounts with one password that is written down in the docs have no place on a server. */
35
+ public static function allowed(): bool
36
+ {
37
+ return ! app()->environment('production');
38
+ }
39
+
40
+ /**
41
+ * Create the first `count` of them who don't exist yet. None, in production.
42
+ *
43
+ * @return list<array{name: string, email: string, role: string, active: bool}> Those created
44
+ */
45
+ public static function create(string $password, int $count = 10): array
46
+ {
47
+ if (! self::allowed() || $password === '') {
48
+ return [];
49
+ }
50
+ $model = Account::model();
51
+ $table = (new $model)->getTable();
52
+ $hash = Hash::make($password);
53
+ $created = [];
54
+
55
+ foreach (array_slice(self::PEOPLE, 0, max(0, $count)) as [$name, $role, $active]) {
56
+ $email = self::email($name);
57
+ if (Account::findByEmail($email)) {
58
+ continue;
59
+ }
60
+ $attributes = ['name' => $name, 'email' => $email, 'password' => $hash];
61
+ if (Schema::hasColumn($table, 'email_verified_at')) {
62
+ $attributes['email_verified_at'] = now();
63
+ }
64
+ if (Schema::hasColumn($table, 'active')) {
65
+ $attributes['active'] = $active;
66
+ }
67
+ $user = (new $model)->forceFill($attributes);
68
+ $user->save();
69
+ Access::grant($user, $role);
70
+ $created[] = ['name' => $name, 'email' => $email, 'role' => $role, 'active' => $active];
71
+ }
72
+
73
+ return $created;
74
+ }
75
+
76
+ /** "Amara Okafor" → amara.okafor@example.com */
77
+ public static function email(string $name): string
78
+ {
79
+ return strtolower(str_replace(' ', '.', $name)).'@example.com';
80
+ }
81
+ }
@@ -2,10 +2,13 @@
2
2
 
3
3
  namespace Nevela\Laravel\Auth;
4
4
 
5
+ use Illuminate\Http\Exceptions\HttpResponseException;
5
6
  use Illuminate\Http\Request;
6
7
  use Illuminate\Support\Facades\DB;
7
8
  use Illuminate\Support\Facades\Schema;
8
9
  use Illuminate\Validation\Rules\Password;
10
+ use Nevela\Laravel\Access\Access;
11
+ use Nevela\Laravel\Access\Permissions;
9
12
  use Nevela\Laravel\Media\Uploads;
10
13
  use Nevela\Laravel\Models\TwoFactor;
11
14
  use RuntimeException;
@@ -35,12 +38,20 @@ final class Account
35
38
  {
36
39
  $avatar = $user->avatar ?? null;
37
40
  $file = is_string($avatar) && $avatar !== '' ? Uploads::ref($avatar) : null;
41
+ $roles = Access::ready() ? Access::rolesOf($user)->pluck('name')->all() : [];
42
+ $grants = Access::grantsFor($user);
38
43
 
39
44
  return [
40
45
  'id' => (string) $user->getAuthIdentifier(),
41
46
  'name' => $user->name ?? null,
42
47
  'email' => $user->email ?? null,
43
- 'role' => $user->role ?? null,
48
+ // The first role, for code written when an account had one; "roles" has them all.
49
+ 'role' => $roles[0] ?? ($user->role ?? null),
50
+ 'roles' => $roles,
51
+ // Everything they may do, patterns already resolved, so a client only looks a
52
+ // permission up. Laravel decides each request for itself whatever a client shows.
53
+ 'permissions' => Permissions::expand($grants),
54
+ 'isAdmin' => Permissions::hasAll($grants),
44
55
  'emailVerified' => ($user->email_verified_at ?? null) !== null,
45
56
  'twoFactorEnabled' => self::twoFactor($user)?->enabled_at !== null,
46
57
  // The key is what is stored; "image" is the small rendition, ready for an <img>.
@@ -62,6 +73,11 @@ final class Account
62
73
  */
63
74
  public static function signIn(mixed $user, Request $request): array
64
75
  {
76
+ // Every way of signing in ends here, so this is the one place a switched-off
77
+ // account is turned away, whichever way it came.
78
+ if (! Access::active($user)) {
79
+ throw new HttpResponseException(response()->json(['error' => 'This account has been switched off. Ask an administrator.', 'code' => 'ACCOUNT_DISABLED'], 403));
80
+ }
65
81
  if (! method_exists($user, 'createToken')) {
66
82
  throw new RuntimeException('Add Laravel\Sanctum\HasApiTokens to '.self::model().' to issue Nevela tokens.');
67
83
  }
@@ -15,9 +15,10 @@ final class SetupCommand extends Command
15
15
  protected $signature = 'nevela:setup
16
16
  {--name= : Name for the starter account}
17
17
  {--email= : Email for the starter account. Leave out to create no account}
18
- {--password= : Password for the starter account}';
18
+ {--password= : Password for the starter account}
19
+ {--no-sample-users : Create the starter account only, without the ten sample users}';
19
20
 
20
- protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations and a starter account';
21
+ protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations, a starter account and sample users';
21
22
 
22
23
  protected $hidden = true;
23
24
 
@@ -39,6 +40,7 @@ final class SetupCommand extends Command
39
40
  }
40
41
 
41
42
  if ($this->option('email')) {
43
+ // The first account in an app is its administrator.
42
44
  $status = $this->call('nevela:user', [
43
45
  '--name' => $this->option('name') ?: 'Admin',
44
46
  '--email' => $this->option('email'),
@@ -48,6 +50,13 @@ final class SetupCommand extends Command
48
50
  // The app itself is fine; the installer says how to add an account by hand.
49
51
  return 2;
50
52
  }
53
+ // Ten more people, with the same password, so the Users screen and the roles
54
+ // have something in them. Only with a password that was given here: one typed
55
+ // at a prompt isn't known to this command. Never in production (the command
56
+ // refuses), and missing them is no reason to call the setup failed.
57
+ if (! $this->option('no-sample-users') && $this->option('password')) {
58
+ $this->callSilently('nevela:user', ['--sample' => true, '--password' => $this->option('password')]);
59
+ }
51
60
  }
52
61
 
53
62
  return self::SUCCESS;
@@ -1,59 +1,151 @@
1
- <?php
2
-
3
- namespace Nevela\Laravel\Console;
4
-
5
- use Illuminate\Console\Command;
6
- use Illuminate\Support\Facades\Hash;
7
- use Illuminate\Support\Facades\Validator;
8
-
9
- final class UserCommand extends Command
10
- {
11
- protected $signature = 'nevela:user
12
- {--name= : The person\'s name}
13
- {--email= : The address they sign in with}
14
- {--password= : Their password. Leave out to be asked for it without it showing}';
15
-
16
- protected $description = 'Create a user who can sign in to the dashboard';
17
-
18
- public function handle(): int
19
- {
20
- $model = config('auth.providers.users.model');
21
- if (! is_string($model) || ! class_exists($model)) {
22
- $this->components->error('No user model is configured in config/auth.php (auth.providers.users.model).');
23
-
24
- return self::FAILURE;
25
- }
26
-
27
- $input = [
28
- 'name' => $this->option('name') ?? $this->ask('Name'),
29
- 'email' => $this->option('email') ?? $this->ask('Email'),
30
- 'password' => $this->option('password') ?? $this->secret('Password (at least 8 characters)'),
31
- ];
32
-
33
- $table = (new $model)->getTable();
34
- $validator = Validator::make($input, [
35
- 'name' => ['required', 'string', 'max:255'],
36
- 'email' => ['required', 'email', 'max:255', "unique:{$table},email"],
37
- 'password' => ['required', 'string', 'min:8'],
38
- ]);
39
- if ($validator->fails()) {
40
- foreach ($validator->errors()->all() as $message) {
41
- $this->components->error($message);
42
- }
43
-
44
- return self::FAILURE;
45
- }
46
-
47
- // forceFill: this is the operator creating an account, not a form to guard.
48
- $user = (new $model)->forceFill([
49
- 'name' => $input['name'],
50
- 'email' => $input['email'],
51
- 'password' => Hash::make($input['password']),
52
- ]);
53
- $user->save();
54
-
55
- $this->components->info("Created {$input['email']}. They can sign in to the dashboard now.");
56
-
57
- return self::SUCCESS;
58
- }
59
- }
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Illuminate\Support\Facades\Hash;
7
+ use Illuminate\Support\Facades\Schema;
8
+ use Illuminate\Support\Facades\Validator;
9
+ use Nevela\Laravel\Access\Access;
10
+ use Nevela\Laravel\Access\SampleUsers;
11
+ use Nevela\Laravel\Auth\Account;
12
+ use Nevela\Laravel\Models\Role;
13
+
14
+ final class UserCommand extends Command
15
+ {
16
+ protected $signature = 'nevela:user
17
+ {--name= : The person\'s name}
18
+ {--email= : The address they sign in with}
19
+ {--password= : Their password. Leave out to be asked for it without it showing}
20
+ {--role= : Their role: ADMIN, EDITOR, USER or one you have made. The first account is an ADMIN}
21
+ {--sample : Create the ten sample users instead: two editors and eight users}';
22
+
23
+ protected $description = 'Create a user who can sign in to the dashboard';
24
+
25
+ public function handle(): int
26
+ {
27
+ $model = config('auth.providers.users.model');
28
+ if (! is_string($model) || ! class_exists($model)) {
29
+ $this->components->error('No user model is configured in config/auth.php (auth.providers.users.model).');
30
+
31
+ return self::FAILURE;
32
+ }
33
+ if ($this->option('sample')) {
34
+ return $this->sample();
35
+ }
36
+
37
+ $asked = $this->option('email') === null;
38
+ $input = [
39
+ 'name' => $this->option('name') ?? $this->ask('Name'),
40
+ // As it will be stored, so that what is checked is what is saved.
41
+ 'email' => mb_strtolower(trim((string) ($this->option('email') ?? $this->ask('Email')))),
42
+ 'password' => $this->option('password') ?? $this->secret('Password (at least 8 characters)'),
43
+ ];
44
+
45
+ $table = (new $model)->getTable();
46
+ $validator = Validator::make($input, [
47
+ 'name' => ['required', 'string', 'max:255'],
48
+ // Compared without regard to capitals, whatever the database does: Ada@ and ada@ are one person.
49
+ 'email' => ['required', 'email', 'max:255', function (string $attribute, mixed $value, \Closure $fail) {
50
+ if (is_string($value) && Account::findByEmail($value)) {
51
+ $fail('Someone already has this email.');
52
+ }
53
+ }],
54
+ 'password' => ['required', 'string', 'min:8'],
55
+ ]);
56
+ if ($validator->fails()) {
57
+ foreach ($validator->errors()->all() as $message) {
58
+ $this->components->error($message);
59
+ }
60
+
61
+ return self::FAILURE;
62
+ }
63
+
64
+ $role = $this->role($asked);
65
+ if ($role === false) {
66
+ return self::FAILURE;
67
+ }
68
+
69
+ // forceFill: this is the operator creating an account, not a form to guard.
70
+ $attributes = ['name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password'])];
71
+ if (Schema::hasColumn($table, 'email_verified_at')) {
72
+ $attributes['email_verified_at'] = now();
73
+ }
74
+ $user = (new $model)->forceFill($attributes);
75
+ $user->save();
76
+ if ($role !== null) {
77
+ Access::grant($user, $role);
78
+ }
79
+
80
+ $as = $role === null ? '' : " as {$role}";
81
+ $this->components->info("Created {$attributes['email']}{$as}. They can sign in to the dashboard now.");
82
+ if ($role !== null && $role !== Role::ADMIN && ! $this->option('role')) {
83
+ $this->line(" To give them another role: the dashboard's Users screen, or <fg=cyan>--role=ADMIN</> here.");
84
+ }
85
+
86
+ return self::SUCCESS;
87
+ }
88
+
89
+ /**
90
+ * The role for the new account: null when this app has no roles yet (its migrations
91
+ * haven't run), false when the one asked for doesn't exist.
92
+ */
93
+ private function role(bool $asked): string|false|null
94
+ {
95
+ if (! Access::ready()) {
96
+ return null;
97
+ }
98
+ $names = Role::query()->orderByDesc('is_system')->orderBy('name')->pluck('name')->all();
99
+ $wanted = $this->option('role');
100
+ if ($wanted !== null) {
101
+ foreach ($names as $name) {
102
+ if (strcasecmp($name, (string) $wanted) === 0) {
103
+ return $name;
104
+ }
105
+ }
106
+ $this->components->error("There is no role called \"{$wanted}\". The roles are: ".implode(', ', $names).'.');
107
+
108
+ return false;
109
+ }
110
+ // Someone has to be able to manage the rest: the first account is the administrator.
111
+ if (Access::otherAdmins() === 0) {
112
+ return Role::ADMIN;
113
+ }
114
+
115
+ return $asked && $this->input->isInteractive() ? (string) $this->choice('Role', $names, Role::USER) : Role::USER;
116
+ }
117
+
118
+ private function sample(): int
119
+ {
120
+ if (! Access::ready()) {
121
+ $this->components->error('Roles need a migration that has not been run: php nevela migrate');
122
+
123
+ return self::FAILURE;
124
+ }
125
+ if (! SampleUsers::allowed()) {
126
+ $this->components->error('Sample users are for development: ten accounts sharing one password. This app is in production, so none were made.');
127
+
128
+ return self::FAILURE;
129
+ }
130
+ $password = (string) ($this->option('password') ?? 'password');
131
+ if (mb_strlen($password) < 8) {
132
+ $this->components->error('Give the sample users a password of at least 8 characters, or leave --password out for "password".');
133
+
134
+ return self::FAILURE;
135
+ }
136
+ $created = SampleUsers::create($password);
137
+ if ($created === []) {
138
+ $this->components->info('The sample users are already here.');
139
+
140
+ return self::SUCCESS;
141
+ }
142
+ foreach ($created as $person) {
143
+ $this->components->twoColumnDetail("{$person['name']} <fg=gray>{$person['email']}</>", $person['role'].($person['active'] ? '' : ' <fg=gray>(switched off)</>'));
144
+ }
145
+ $this->newLine();
146
+ $this->components->info('Created '.count($created)." sample users. They all sign in with the password \"{$password}\".");
147
+ $this->line(' They are in this database only. Delete them before real use: the dashboard\'s Users screen.');
148
+
149
+ return self::SUCCESS;
150
+ }
151
+ }
@@ -806,34 +806,35 @@ final class ResourceGenerator
806
806
  /**
807
807
  * Who may do what with {$d->pluralLabel}. Generated once — this file is yours.
808
808
  *
809
- * Default: any signed-in user (routes already require auth:sanctum). Tighten this
810
- * before production, e.g. `return in_array(\$user->role, ['admin', 'staff']);`.
809
+ * Each action asks for a permission, which a person has through their roles
810
+ * (the dashboard's Roles screen). Add your own conditions beside them, e.g.
811
+ * `return \$user->can('{$d->table}.edit') && \${$var}->user_id === \$user->id;`.
811
812
  */
812
813
  class {$d->name}Policy
813
814
  {
814
815
  public function viewAny(User \$user): bool
815
816
  {
816
- return true;
817
+ return \$user->can('{$d->table}.view');
817
818
  }
818
819
 
819
820
  public function view(User \$user, {$d->name} \${$var}): bool
820
821
  {
821
- return true;
822
+ return \$user->can('{$d->table}.view');
822
823
  }
823
824
 
824
825
  public function create(User \$user): bool
825
826
  {
826
- return true;
827
+ return \$user->can('{$d->table}.create');
827
828
  }
828
829
 
829
830
  public function update(User \$user, {$d->name} \${$var}): bool
830
831
  {
831
- return true;
832
+ return \$user->can('{$d->table}.edit');
832
833
  }
833
834
 
834
835
  public function delete(User \$user, {$d->name} \${$var}): bool
835
836
  {
836
- return true;
837
+ return \$user->can('{$d->table}.delete');
837
838
  }
838
839
  }
839
840
 
@@ -0,0 +1,24 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http\Access;
4
+
5
+ use Closure;
6
+ use Illuminate\Http\Request;
7
+ use Nevela\Laravel\Access\Access;
8
+ use Symfony\Component\HttpFoundation\Response;
9
+
10
+ /**
11
+ * The users and roles endpoints need tables that an upgraded app doesn't have until it
12
+ * has run its migrations. Until then they say so, in place of a database error.
13
+ */
14
+ final class RolesAreSetUp
15
+ {
16
+ public function handle(Request $request, Closure $next): Response
17
+ {
18
+ if (! Access::ready()) {
19
+ return response()->json(['error' => 'Roles need a migration that has not been run: php nevela migrate', 'code' => 'MIGRATION_NEEDED'], 409);
20
+ }
21
+
22
+ return $next($request);
23
+ }
24
+ }
@@ -0,0 +1,197 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http\Access;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Request;
7
+ use Illuminate\Http\Response;
8
+ use Illuminate\Support\Facades\DB;
9
+ use Illuminate\Support\Facades\Gate;
10
+ use Illuminate\Validation\Rule;
11
+ use Nevela\Laravel\Access\Access;
12
+ use Nevela\Laravel\Access\Permissions;
13
+ use Nevela\Laravel\Http\Auth\Answers;
14
+ use Nevela\Laravel\Models\Role;
15
+ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
16
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
17
+
18
+ /**
19
+ * Roles: named sets of grants.
20
+ *
21
+ * The same ceiling as for users applies: a role can be given only what its editor holds,
22
+ * so editing roles is never a way to gain a permission. The ADMIN role always grants
23
+ * everything, and the three built-in roles keep their names and can't be deleted.
24
+ */
25
+ final class RolesController
26
+ {
27
+ use Answers;
28
+
29
+ /** GET _nevela/permissions: every permission there is, shaped for the roles screen. */
30
+ public function catalog(): JsonResponse
31
+ {
32
+ Gate::authorize('roles.view');
33
+
34
+ return response()->json(['modules' => Permissions::catalog(), 'total' => count(Permissions::keys())]);
35
+ }
36
+
37
+ /** GET _nevela/roles. Open to anyone who manages users too: the user form lists them. */
38
+ public function index(Request $request): JsonResponse
39
+ {
40
+ $user = $request->user();
41
+ if (! $user->can('roles.view') && ! $user->can('users.view')) {
42
+ throw new AccessDeniedHttpException("You don't have access to this.");
43
+ }
44
+ $counts = DB::table('nevela_role_user')->groupBy('role_id')->selectRaw('role_id, count(*) as users')->pluck('users', 'role_id');
45
+ $held = Access::grantsFor($user);
46
+ $roles = Role::query()->orderByDesc('is_system')->orderBy('name')->get();
47
+
48
+ return response()->json(['data' => $roles->map(fn (Role $role) => self::present($role, (int) ($counts[$role->id] ?? 0), $held))->all()]);
49
+ }
50
+
51
+ /** GET _nevela/roles/{id} */
52
+ public function show(Request $request, string $id): JsonResponse
53
+ {
54
+ Gate::authorize('roles.view');
55
+ $role = $this->find($id);
56
+
57
+ return response()->json(self::present($role, $this->users($role), Access::grantsFor($request->user())));
58
+ }
59
+
60
+ /** POST _nevela/roles */
61
+ public function store(Request $request): JsonResponse
62
+ {
63
+ Gate::authorize('roles.create');
64
+ $input = $this->validated($request, null);
65
+ if ($refused = $this->beyondCaller($request, $input['grants'])) {
66
+ return $refused;
67
+ }
68
+ $role = Role::query()->create(['name' => $input['name'], 'description' => $input['description'] ?? null, 'grants' => $input['grants'], 'is_system' => false]);
69
+
70
+ return response()->json(self::present($role, 0, Access::grantsFor($request->user())), 201);
71
+ }
72
+
73
+ /** PATCH _nevela/roles/{id}: any of name, description, grants. */
74
+ public function update(Request $request, string $id): JsonResponse
75
+ {
76
+ Gate::authorize('roles.edit');
77
+ $role = $this->find($id);
78
+ $input = $this->validated($request, $role);
79
+ $held = Access::grantsFor($request->user());
80
+
81
+ // A role that already allows more than its editor holds is not theirs to reshape.
82
+ if (Permissions::beyond($held, $role->grants) !== []) {
83
+ return $this->refuse('BEYOND_YOUR_OWN', "You can't change the {$role->name} role: it allows things your own roles don't.", 403);
84
+ }
85
+ if ($role->is_system && isset($input['name']) && $input['name'] !== $role->name) {
86
+ return $this->refuse('BUILT_IN', "The {$role->name} role is built in. It can be edited, and not renamed.", 409);
87
+ }
88
+ if (array_key_exists('grants', $input)) {
89
+ if ($role->name === Role::ADMIN && $role->is_system && ! in_array('*', $input['grants'], true)) {
90
+ return $this->refuse('BUILT_IN', 'The ADMIN role always allows everything. Make another role for less.', 409);
91
+ }
92
+ if ($refused = $this->beyondCaller($request, $input['grants'])) {
93
+ return $refused;
94
+ }
95
+ }
96
+
97
+ $role->fill(array_intersect_key($input, array_flip(['name', 'description', 'grants'])))->save();
98
+ Access::forget();
99
+
100
+ return response()->json(self::present($role, $this->users($role), $held));
101
+ }
102
+
103
+ /** DELETE _nevela/roles/{id} */
104
+ public function destroy(Request $request, string $id): JsonResponse|Response
105
+ {
106
+ Gate::authorize('roles.delete');
107
+ $role = $this->find($id);
108
+ if ($role->is_system) {
109
+ return $this->refuse('BUILT_IN', "The {$role->name} role is built in and can't be deleted.", 409);
110
+ }
111
+ if (Permissions::beyond(Access::grantsFor($request->user()), $role->grants) !== []) {
112
+ return $this->refuse('BEYOND_YOUR_OWN', "You can't delete the {$role->name} role: it allows things your own roles don't.", 403);
113
+ }
114
+ // Deleting it would quietly take its permissions from everyone who holds it.
115
+ if (($users = $this->users($role)) > 0) {
116
+ return $this->refuse('IN_USE', $users === 1 ? 'One user has this role. Give them another before deleting it.' : "{$users} users have this role. Give them another before deleting it.", 409);
117
+ }
118
+ $role->delete();
119
+ Access::forget();
120
+
121
+ return response()->noContent();
122
+ }
123
+
124
+ private function find(string $id): Role
125
+ {
126
+ return Role::query()->find($id) ?? throw new NotFoundHttpException('No such role.');
127
+ }
128
+
129
+ private function users(Role $role): int
130
+ {
131
+ return DB::table('nevela_role_user')->where('role_id', $role->id)->count();
132
+ }
133
+
134
+ /** @return array{name?: string, description?: string|null, grants?: list<string>} */
135
+ private function validated(Request $request, ?Role $role): array
136
+ {
137
+ $sometimes = $role ? ['sometimes'] : [];
138
+ $input = $request->validate([
139
+ 'name' => [...$sometimes, 'required', 'string', 'min:2', 'max:80', 'regex:/^[\pL\pN][\pL\pN _-]*$/u', function (string $attribute, mixed $value, \Closure $fail) use ($role) {
140
+ // "Support" and "support" would be two roles nobody could tell apart.
141
+ $taken = Role::query()->whereRaw('lower(name) = ?', [mb_strtolower((string) $value)])->when($role, fn ($query) => $query->where('id', '!=', $role->id))->exists();
142
+ if ($taken) {
143
+ $fail('There is already a role with this name.');
144
+ }
145
+ }],
146
+ 'description' => ['sometimes', 'nullable', 'string', 'max:500'],
147
+ 'grants' => [...$sometimes, 'present', 'array', 'max:2000'],
148
+ // bail: what follows reads the value as text, which a list sent in its place is not.
149
+ 'grants.*' => ['bail', 'string', 'max:120', function (string $attribute, mixed $value, \Closure $fail) {
150
+ if (! is_string($value) || ! Permissions::understood($value)) {
151
+ $fail(is_string($value) ? "\"{$value}\" isn't a permission this app has." : 'A grant is a piece of text.');
152
+ }
153
+ }],
154
+ ], ['name.regex' => 'Use letters, numbers, spaces, dashes and underscores.']);
155
+ if (isset($input['grants'])) {
156
+ $input['grants'] = array_values(array_unique($input['grants']));
157
+ }
158
+
159
+ return $input;
160
+ }
161
+
162
+ /** @param list<string> $grants */
163
+ private function beyondCaller(Request $request, array $grants): ?JsonResponse
164
+ {
165
+ $beyond = Permissions::beyond(Access::grantsFor($request->user()), $grants);
166
+ if ($beyond === []) {
167
+ return null;
168
+ }
169
+
170
+ return response()->json([
171
+ 'error' => "A role can't allow more than you hold yourself: ".implode(', ', array_slice($beyond, 0, 6)).(count($beyond) > 6 ? ', and '.(count($beyond) - 6).' more' : '').'.',
172
+ 'code' => 'BEYOND_YOUR_OWN',
173
+ 'beyond' => $beyond,
174
+ ], 403);
175
+ }
176
+
177
+ /**
178
+ * @param list<string> $held The grants of whoever is asking
179
+ * @return array<string, mixed>
180
+ */
181
+ public static function present(Role $role, int $users, array $held): array
182
+ {
183
+ return [
184
+ 'id' => $role->id,
185
+ 'name' => $role->name,
186
+ 'description' => $role->description,
187
+ // As written, patterns included, and what they come to today.
188
+ 'grants' => $role->grants,
189
+ 'permissions' => Permissions::expand($role->grants),
190
+ 'isSystem' => (bool) $role->is_system,
191
+ 'isAdmin' => Permissions::hasAll($role->grants),
192
+ 'users' => $users,
193
+ // Whether whoever is asking may give this role to someone, or change it.
194
+ 'withinYours' => Permissions::beyond($held, $role->grants) === [],
195
+ ];
196
+ }
197
+ }