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
@@ -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
+ }
@@ -0,0 +1,382 @@
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\Collection;
9
+ use Illuminate\Support\Facades\DB;
10
+ use Illuminate\Support\Facades\Gate;
11
+ use Illuminate\Support\Facades\Hash;
12
+ use Illuminate\Support\Facades\Schema;
13
+ use Illuminate\Validation\Rule;
14
+ use Nevela\Laravel\Access\Access;
15
+ use Nevela\Laravel\Access\Permissions;
16
+ use Nevela\Laravel\Auth\Account;
17
+ use Nevela\Laravel\Auth\AuthMail;
18
+ use Nevela\Laravel\Http\Auth\Answers;
19
+ use Nevela\Laravel\Media\Uploads;
20
+ use Nevela\Laravel\Models\Role;
21
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
22
+
23
+ /**
24
+ * The people who can sign in, for someone allowed to manage them.
25
+ *
26
+ * Three rules sit on top of the permissions (users.view, .create, .edit, .delete), so that
27
+ * managing users is never a way to take more than you were given:
28
+ *
29
+ * - you hand out only roles whose grants you hold yourself;
30
+ * - you change only accounts that may do no more than you. Otherwise setting someone's
31
+ * password, or their email and then asking for a reset, would be a way to sign in as
32
+ * them and have what they have;
33
+ * - the last administrator can't be removed, switched off or demoted, by anyone.
34
+ */
35
+ final class UsersController
36
+ {
37
+ use Answers;
38
+
39
+ /** GET _nevela/users?q=&role=&status=&page=&perPage= */
40
+ public function index(Request $request): JsonResponse
41
+ {
42
+ Gate::authorize('users.view');
43
+ $input = $request->validate([
44
+ 'q' => ['nullable', 'string', 'max:200'],
45
+ 'role' => ['nullable', 'string', 'max:64'],
46
+ 'status' => ['nullable', Rule::in(['active', 'inactive'])],
47
+ 'page' => ['nullable', 'integer', 'min:1'],
48
+ 'perPage' => ['nullable', 'integer', 'min:1', 'max:100'],
49
+ ]);
50
+ $model = Account::model();
51
+ $instance = new $model;
52
+ $query = $model::query();
53
+
54
+ if (($search = trim((string) ($input['q'] ?? ''))) !== '') {
55
+ $pattern = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_strtolower($search)).'%';
56
+ $query->where(fn ($where) => $where->whereRaw('lower(name) like ?', [$pattern])->orWhereRaw('lower(email) like ?', [$pattern]));
57
+ }
58
+ if (! empty($input['role'])) {
59
+ // Read as a list first: the link table keeps ids as text, whatever type they are here.
60
+ $query->whereIn($instance->getKeyName(), DB::table('nevela_role_user')->where('role_id', $input['role'])->pluck('user_id')->all());
61
+ }
62
+ if (isset($input['status']) && Schema::hasColumn($instance->getTable(), 'active')) {
63
+ $query->where('active', $input['status'] === 'active');
64
+ }
65
+
66
+ $perPage = (int) ($input['perPage'] ?? 25);
67
+ $page = (int) ($input['page'] ?? 1);
68
+ $total = (clone $query)->count();
69
+ $users = $query->orderBy('name')->orderBy($instance->getKeyName())->forPage($page, $perPage)->get();
70
+
71
+ return response()->json([
72
+ 'data' => self::present($users, $request->user()),
73
+ 'meta' => ['page' => $page, 'perPage' => $perPage, 'total' => $total, 'totalPages' => max(1, (int) ceil($total / $perPage))],
74
+ ]);
75
+ }
76
+
77
+ /** GET _nevela/users/{id} */
78
+ public function show(Request $request, string $id): JsonResponse
79
+ {
80
+ Gate::authorize('users.view');
81
+
82
+ return response()->json(self::present(collect([$this->find($id)]), $request->user())[0]);
83
+ }
84
+
85
+ /** POST _nevela/users */
86
+ public function store(Request $request): JsonResponse
87
+ {
88
+ Gate::authorize('users.create');
89
+ $model = Account::model();
90
+ $user = new $model;
91
+ $input = $request->validate([
92
+ 'name' => ['required', 'string', 'max:255'],
93
+ 'email' => ['required', 'email', 'max:255'],
94
+ 'password' => Account::passwordRules(),
95
+ 'roles' => ['present', 'array', 'max:50'],
96
+ 'roles.*' => ['string', 'max:64'],
97
+ 'active' => ['nullable', 'boolean'],
98
+ ]);
99
+ if (Account::findByEmail($input['email'])) {
100
+ return response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => 'Someone already has this email.']]], 422);
101
+ }
102
+ $roles = $this->roles($input['roles']);
103
+ if ($roles instanceof JsonResponse) {
104
+ return $roles;
105
+ }
106
+ if ($refused = $this->beyondCaller($request, $roles)) {
107
+ return $refused;
108
+ }
109
+
110
+ $attributes = ['name' => $input['name'], 'email' => mb_strtolower(trim($input['email'])), 'password' => Hash::make($input['password'])];
111
+ if (Schema::hasColumn($user->getTable(), 'active')) {
112
+ $attributes['active'] = (bool) ($input['active'] ?? true);
113
+ }
114
+ if (Schema::hasColumn($user->getTable(), 'email_verified_at')) {
115
+ // Someone allowed to create users vouches for the address.
116
+ $attributes['email_verified_at'] = now();
117
+ }
118
+ $user->forceFill($attributes)->save();
119
+ Access::assign($user, $roles->pluck('id')->all());
120
+
121
+ return response()->json(self::present(collect([$user]), $request->user())[0], 201);
122
+ }
123
+
124
+ /** PATCH _nevela/users/{id}: any of name, email, password, roles, active. */
125
+ public function update(Request $request, string $id): JsonResponse
126
+ {
127
+ Gate::authorize('users.edit');
128
+ $user = $this->find($id);
129
+ $caller = $request->user();
130
+ $self = (string) $caller->getAuthIdentifier() === (string) $user->getAuthIdentifier();
131
+ $input = $request->validate([
132
+ 'name' => ['sometimes', 'required', 'string', 'max:255'],
133
+ 'email' => ['sometimes', 'required', 'email', 'max:255'],
134
+ 'password' => ['sometimes', ...Account::passwordRules()],
135
+ 'roles' => ['sometimes', 'array', 'max:50'],
136
+ 'roles.*' => ['string', 'max:64'],
137
+ 'active' => ['sometimes', 'boolean'],
138
+ ]);
139
+
140
+ $wasAdmin = $this->holdsEverything($user);
141
+ if (! $self && ($refused = $this->aboveCaller($caller, $user, 'change'))) {
142
+ return $refused;
143
+ }
144
+ if (isset($input['email'])) {
145
+ $taken = Account::findByEmail($input['email']);
146
+ if ($taken && (string) $taken->getAuthIdentifier() !== (string) $user->getAuthIdentifier()) {
147
+ return response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => 'Someone already has this email.']]], 422);
148
+ }
149
+ }
150
+
151
+ $roles = null;
152
+ if (array_key_exists('roles', $input)) {
153
+ $roles = $this->roles($input['roles']);
154
+ if ($roles instanceof JsonResponse) {
155
+ return $roles;
156
+ }
157
+ // Only what is being added is checked: a role they already hold isn't being handed out.
158
+ $held = Access::rolesOf($user)->pluck('id')->all();
159
+ if ($refused = $this->beyondCaller($request, $roles->reject(fn (Role $role) => in_array($role->id, $held, true)))) {
160
+ return $refused;
161
+ }
162
+ }
163
+
164
+ $deactivating = array_key_exists('active', $input) && ! $input['active'] && Access::active($user);
165
+ if ($deactivating && $self) {
166
+ return $this->refuse('SELF', "You can't switch your own account off. Ask another administrator.", 409);
167
+ }
168
+ $demoting = $roles !== null && $wasAdmin && ! $roles->contains(fn (Role $role) => Permissions::hasAll($role->grants));
169
+ if (($deactivating || $demoting) && $wasAdmin && Access::active($user) && Access::otherAdmins($user) === 0) {
170
+ return $this->refuse('LAST_ADMIN', 'This is the only administrator. Make someone else an administrator first.', 409);
171
+ }
172
+
173
+ $attributes = [];
174
+ if (isset($input['name'])) {
175
+ $attributes['name'] = $input['name'];
176
+ }
177
+ if (isset($input['email'])) {
178
+ $attributes['email'] = mb_strtolower(trim($input['email']));
179
+ }
180
+ if (isset($input['password'])) {
181
+ $attributes['password'] = Hash::make($input['password']);
182
+ }
183
+ if (array_key_exists('active', $input) && Schema::hasColumn($user->getTable(), 'active')) {
184
+ $attributes['active'] = (bool) $input['active'];
185
+ }
186
+ if ($attributes !== []) {
187
+ $user->forceFill($attributes)->save();
188
+ }
189
+ if ($roles !== null) {
190
+ Access::assign($user, $roles->pluck('id')->all());
191
+ }
192
+
193
+ // A new password or a switched-off account ends every session it had. Their own
194
+ // session survives their own password change, as it does on the account page.
195
+ if ($deactivating || isset($input['password'])) {
196
+ Account::revokeTokens($user, $self ? $caller->currentAccessToken()?->getKey() : null);
197
+ }
198
+ if (isset($input['password']) && ! $self) {
199
+ AuthMail::send($user->email, 'Your '.AuthMail::app().' password was changed', ['An administrator set a new password for your account.', "If you weren't expecting this, ask them about it before signing in."]);
200
+ }
201
+ Access::forget();
202
+
203
+ return response()->json(self::present(collect([$user->refresh()]), $caller)[0]);
204
+ }
205
+
206
+ /** DELETE _nevela/users/{id} */
207
+ public function destroy(Request $request, string $id): JsonResponse|Response
208
+ {
209
+ Gate::authorize('users.delete');
210
+ $user = $this->find($id);
211
+ $caller = $request->user();
212
+ if ((string) $caller->getAuthIdentifier() === (string) $user->getAuthIdentifier()) {
213
+ return $this->refuse('SELF', "You can't delete your own account here.", 409);
214
+ }
215
+ if ($refused = $this->aboveCaller($caller, $user, 'delete')) {
216
+ return $refused;
217
+ }
218
+ if ($this->holdsEverything($user) && Access::active($user) && Access::otherAdmins($user) === 0) {
219
+ return $this->refuse('LAST_ADMIN', 'This is the only administrator. Make someone else an administrator first.', 409);
220
+ }
221
+
222
+ $key = (string) $user->getAuthIdentifier();
223
+ DB::transaction(function () use ($user, $key) {
224
+ Account::revokeTokens($user);
225
+ DB::table('nevela_role_user')->where('user_id', $key)->delete();
226
+ foreach (['nevela_two_factor', 'nevela_passkeys'] as $table) {
227
+ if (Schema::hasTable($table)) {
228
+ DB::table($table)->where('user_id', $key)->delete();
229
+ }
230
+ }
231
+ $user->delete();
232
+ });
233
+ Access::forget();
234
+
235
+ return response()->noContent();
236
+ }
237
+
238
+ /** DELETE _nevela/users/{id}/sessions: sign them out of every device. */
239
+ public function revokeSessions(Request $request, string $id): JsonResponse
240
+ {
241
+ Gate::authorize('users.edit');
242
+ $user = $this->find($id);
243
+ $self = (string) $request->user()->getAuthIdentifier() === (string) $user->getAuthIdentifier();
244
+ if (! $self && ($refused = $this->aboveCaller($request->user(), $user, 'sign out'))) {
245
+ return $refused;
246
+ }
247
+
248
+ return response()->json(['revoked' => Account::revokeTokens($user, $self ? $request->user()->currentAccessToken()?->getKey() : null)]);
249
+ }
250
+
251
+ private function find(string $id): mixed
252
+ {
253
+ return Account::model()::query()->find($id) ?? throw new NotFoundHttpException('No such user.');
254
+ }
255
+
256
+ /**
257
+ * A refusal when the account may do something the caller may not, and null otherwise.
258
+ *
259
+ * What the account may do is read from its roles, not from whether it is switched on:
260
+ * a switched-off administrator is still not for a lesser manager to give a new
261
+ * password and switch back on.
262
+ */
263
+ private function aboveCaller(mixed $caller, mixed $user, string $verb): ?JsonResponse
264
+ {
265
+ if (self::within(Access::grantsFor($caller), $user)) {
266
+ return null;
267
+ }
268
+
269
+ return $this->holdsEverything($user)
270
+ ? $this->refuse('ADMIN_ONLY', "Only an administrator can {$verb} an administrator's account.", 403)
271
+ : $this->refuse('ABOVE_YOUR_OWN', "You can't {$verb} this account: it may do things your own roles don't allow.", 403);
272
+ }
273
+
274
+ /**
275
+ * Whether everything an account's roles allow is covered by `$held`.
276
+ *
277
+ * @param list<string> $held
278
+ * @param iterable<Role>|null $roles The account's roles, when they are already at hand
279
+ */
280
+ private static function within(array $held, mixed $user, ?iterable $roles = null): bool
281
+ {
282
+ $theirs = [];
283
+ foreach ($roles ?? Access::rolesOf($user) as $role) {
284
+ array_push($theirs, ...$role->grants);
285
+ }
286
+
287
+ return Permissions::beyond($held, $theirs) === [];
288
+ }
289
+
290
+ /** Whether this account holds a role that comes to everything. */
291
+ private function holdsEverything(mixed $user): bool
292
+ {
293
+ return Access::rolesOf($user)->contains(fn (Role $role) => Permissions::hasAll($role->grants));
294
+ }
295
+
296
+ /**
297
+ * The roles named by id, or a 422 when one doesn't exist.
298
+ *
299
+ * @param list<string> $ids
300
+ * @return Collection<int, Role>|JsonResponse
301
+ */
302
+ private function roles(array $ids): Collection|JsonResponse
303
+ {
304
+ $ids = array_values(array_unique($ids));
305
+ $roles = Role::query()->whereIn('id', $ids)->get();
306
+ if ($roles->count() !== count($ids)) {
307
+ return response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'roles', 'message' => 'One of those roles no longer exists. Reload and try again.']]], 422);
308
+ }
309
+
310
+ return $roles;
311
+ }
312
+
313
+ /** @param Collection<int, Role> $roles */
314
+ private function beyondCaller(Request $request, Collection $roles): ?JsonResponse
315
+ {
316
+ $held = Access::grantsFor($request->user());
317
+ foreach ($roles as $role) {
318
+ if (Permissions::beyond($held, $role->grants) !== []) {
319
+ return $this->refuse('BEYOND_YOUR_OWN', "You can't give someone the {$role->name} role: it allows things your own roles don't.", 403);
320
+ }
321
+ }
322
+
323
+ return null;
324
+ }
325
+
326
+ /**
327
+ * Users as the dashboard shows them, with what a list needs asked for once and not per row.
328
+ *
329
+ * @param Collection<int, mixed> $users
330
+ * @return list<array<string, mixed>>
331
+ */
332
+ public static function present(Collection $users, mixed $caller): array
333
+ {
334
+ $ids = $users->map(fn ($user) => (string) $user->getAuthIdentifier())->all();
335
+ if ($ids === []) {
336
+ return [];
337
+ }
338
+ $roles = Role::query()->get()->keyBy('id');
339
+ $held = [];
340
+ foreach (DB::table('nevela_role_user')->whereIn('user_id', $ids)->get() as $link) {
341
+ if ($role = $roles->get($link->role_id)) {
342
+ $held[$link->user_id][] = $role;
343
+ }
344
+ }
345
+ $secondStep = Schema::hasTable('nevela_two_factor')
346
+ ? DB::table('nevela_two_factor')->whereIn('user_id', $ids)->whereNotNull('enabled_at')->pluck('user_id')->flip()
347
+ : collect();
348
+ $first = $users->first();
349
+ $devices = DB::table('personal_access_tokens')
350
+ ->where('tokenable_type', $first->getMorphClass())->whereIn('tokenable_id', $ids)
351
+ ->groupBy('tokenable_id')->selectRaw('tokenable_id, count(*) as devices, max(last_used_at) as last_used_at')
352
+ ->get()->keyBy(fn ($row) => (string) $row->tokenable_id);
353
+
354
+ $callerGrants = Access::grantsFor($caller);
355
+
356
+ return $users->map(function ($user) use ($held, $secondStep, $devices, $caller, $callerGrants) {
357
+ $id = (string) $user->getAuthIdentifier();
358
+ $mine = collect($held[$id] ?? [])->sortBy('name')->values();
359
+ $avatar = $user->avatar ?? null;
360
+ $file = is_string($avatar) && $avatar !== '' ? Uploads::ref($avatar) : null;
361
+ $seen = $devices->get($id);
362
+
363
+ return [
364
+ 'id' => $id,
365
+ 'name' => $user->name ?? null,
366
+ 'email' => $user->email ?? null,
367
+ 'active' => Access::active($user),
368
+ 'emailVerified' => ($user->email_verified_at ?? null) !== null,
369
+ 'twoFactorEnabled' => $secondStep->has($id),
370
+ 'image' => $file ? ($file['renditions']->thumb['url'] ?? $file['url']) : null,
371
+ 'roles' => $mine->map(fn (Role $role) => ['id' => $role->id, 'name' => $role->name])->all(),
372
+ 'isAdmin' => $mine->contains(fn (Role $role) => Permissions::hasAll($role->grants)),
373
+ 'isSelf' => $id === (string) $caller->getAuthIdentifier(),
374
+ // Whether whoever is asking may change this account: it may do no more than they may.
375
+ 'withinYours' => self::within($callerGrants, $user, $mine),
376
+ 'devices' => (int) ($seen->devices ?? 0),
377
+ 'lastActiveAt' => ! empty($seen?->last_used_at) ? date(DATE_ATOM, strtotime($seen->last_used_at)) : null,
378
+ 'createdAt' => $user->created_at?->toAtomString(),
379
+ ];
380
+ })->all();
381
+ }
382
+ }
@@ -8,6 +8,7 @@ use Illuminate\Http\Response;
8
8
  use Illuminate\Support\Facades\DB;
9
9
  use Illuminate\Support\Facades\Hash;
10
10
  use Illuminate\Support\Facades\Schema;
11
+ use Nevela\Laravel\Access\Access;
11
12
  use Nevela\Laravel\Auth\Account;
12
13
  use Nevela\Laravel\Auth\AuthMail;
13
14
  use Nevela\Laravel\Auth\Challenges;
@@ -46,11 +47,14 @@ final class AccountController
46
47
 
47
48
  $user = new $model;
48
49
  $attributes = ['name' => $input['name'], 'email' => mb_strtolower(trim($input['email'])), 'password' => Hash::make($input['password'])];
49
- $role = config('nevela.auth.default_role');
50
+ $role = config('nevela.auth.default_role', 'USER');
50
51
  if ($role !== null && Schema::hasColumn($user->getTable(), 'role')) {
51
52
  $attributes['role'] = $role;
52
53
  }
53
54
  $user->forceFill($attributes)->save();
55
+ if (is_string($role) && $role !== '') {
56
+ Access::grant($user, $role);
57
+ }
54
58
  self::sendVerification($user, $request);
55
59
 
56
60
  if (config('nevela.auth.require_email_verification', false)) {