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.
- package/README.md +2 -0
- package/index.mjs +92 -22
- package/package.json +1 -1
- package/template/laravel/config/nevela.php +17 -2
- package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
- package/template/laravel/src/Access/Access.php +168 -0
- package/template/laravel/src/Access/Permissions.php +271 -0
- package/template/laravel/src/Access/SampleUsers.php +81 -0
- package/template/laravel/src/Auth/Account.php +17 -1
- package/template/laravel/src/Console/SetupCommand.php +11 -2
- package/template/laravel/src/Console/UserCommand.php +151 -59
- package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
- package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
- package/template/laravel/src/Http/Access/RolesController.php +197 -0
- package/template/laravel/src/Http/Access/UsersController.php +382 -0
- package/template/laravel/src/Http/Auth/AccountController.php +5 -1
- package/template/laravel/src/Models/Role.php +73 -0
- package/template/laravel/src/Nevela.php +2 -1
- package/template/laravel/src/NevelaServiceProvider.php +29 -0
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/dashboard/access/actions.ts +70 -0
- package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
- package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
- package/template/web/app/dashboard/access/roles/page.tsx +77 -0
- package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
- package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
- package/template/web/app/dashboard/access/users/page.tsx +158 -0
- package/template/web/app/dashboard/actions.ts +10 -9
- package/template/web/app/dashboard/import-actions.ts +6 -5
- package/template/web/app/dashboard/layout.tsx +5 -2
- package/template/web/app/dashboard/page.tsx +27 -4
- package/template/web/components/access/role-form.tsx +389 -0
- package/template/web/components/access/user-form.tsx +167 -0
- package/template/web/components/access/user-row-actions.tsx +117 -0
- package/template/web/components/access/users-toolbar.tsx +82 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
- package/template/web/lib/access.ts +107 -0
- package/template/web/lib/dashboard.ts +51 -18
- package/template/web/lib/session.ts +11 -0
- package/template/web/package.json +1 -1
- 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
|
-
*
|
|
810
|
-
*
|
|
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
|
|
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
|
|
822
|
+
return \$user->can('{$d->table}.view');
|
|
822
823
|
}
|
|
823
824
|
|
|
824
825
|
public function create(User \$user): bool
|
|
825
826
|
{
|
|
826
|
-
return
|
|
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
|
|
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
|
|
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)) {
|