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.
- package/README.md +2 -0
- package/index.mjs +24 -10
- 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
|
@@ -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)) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Models;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
6
|
+
use Illuminate\Database\Eloquent\Model;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A named set of grants. See Nevela\Laravel\Access\Permissions for what a grant is.
|
|
10
|
+
*
|
|
11
|
+
* Grants are kept as written, patterns included: a role holding "products.*" goes on
|
|
12
|
+
* covering products when an action is added, which a list of the permissions it came to
|
|
13
|
+
* on the day it was saved would not.
|
|
14
|
+
*
|
|
15
|
+
* @property string $id
|
|
16
|
+
* @property string $name
|
|
17
|
+
* @property string|null $description
|
|
18
|
+
* @property list<string> $grants
|
|
19
|
+
* @property bool $is_system
|
|
20
|
+
*/
|
|
21
|
+
class Role extends Model
|
|
22
|
+
{
|
|
23
|
+
use HasUuids;
|
|
24
|
+
|
|
25
|
+
/** The three every app starts with. They can be edited, and not renamed or deleted. */
|
|
26
|
+
public const ADMIN = 'ADMIN';
|
|
27
|
+
|
|
28
|
+
public const EDITOR = 'EDITOR';
|
|
29
|
+
|
|
30
|
+
public const USER = 'USER';
|
|
31
|
+
|
|
32
|
+
protected $table = 'nevela_roles';
|
|
33
|
+
|
|
34
|
+
protected $guarded = [];
|
|
35
|
+
|
|
36
|
+
protected function casts(): array
|
|
37
|
+
{
|
|
38
|
+
return ['is_system' => 'boolean'];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The grants, as a list. A value that can't be read comes to no grants at all: this
|
|
43
|
+
* decides who may do what, and the safe way to fail is to refuse.
|
|
44
|
+
*
|
|
45
|
+
* @return list<string>
|
|
46
|
+
*/
|
|
47
|
+
public function getGrantsAttribute(mixed $value): array
|
|
48
|
+
{
|
|
49
|
+
$decoded = is_string($value) ? json_decode($value, true) : $value;
|
|
50
|
+
|
|
51
|
+
return is_array($decoded) ? array_values(array_filter($decoded, 'is_string')) : [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param list<string>|null $value */
|
|
55
|
+
public function setGrantsAttribute(mixed $value): void
|
|
56
|
+
{
|
|
57
|
+
$this->attributes['grants'] = json_encode(array_values(array_unique(array_filter((array) $value, 'is_string'))));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The roles a new app is given.
|
|
62
|
+
*
|
|
63
|
+
* @return list<array{name: string, description: string, grants: list<string>}>
|
|
64
|
+
*/
|
|
65
|
+
public static function defaults(): array
|
|
66
|
+
{
|
|
67
|
+
return [
|
|
68
|
+
['name' => self::ADMIN, 'description' => 'Full access to every feature and action.', 'grants' => ['*']],
|
|
69
|
+
['name' => self::EDITOR, 'description' => 'Works with the app\'s records. Sees who the users are, and manages neither users nor roles.', 'grants' => ['@resources.*', 'users.view']],
|
|
70
|
+
['name' => self::USER, 'description' => 'A standard account. Signs in and manages its own profile, and nothing else.', 'grants' => []],
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -18,7 +18,7 @@ use Nevela\Laravel\Support\Naming;
|
|
|
18
18
|
final class Nevela
|
|
19
19
|
{
|
|
20
20
|
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
21
|
-
public const VERSION = '0.
|
|
21
|
+
public const VERSION = '0.5.0';
|
|
22
22
|
|
|
23
23
|
/** @var array<string, Descriptor> */
|
|
24
24
|
private static array $descriptors = [];
|
|
@@ -63,6 +63,7 @@ final class Nevela
|
|
|
63
63
|
public static function forget(): void
|
|
64
64
|
{
|
|
65
65
|
self::$descriptors = [];
|
|
66
|
+
Access\Permissions::forget();
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/**
|
|
@@ -6,9 +6,12 @@ use Illuminate\Contracts\Debug\ExceptionHandler;
|
|
|
6
6
|
use Illuminate\Cache\RateLimiting\Limit;
|
|
7
7
|
use Illuminate\Foundation\Console\ServeCommand;
|
|
8
8
|
use Illuminate\Http\Request;
|
|
9
|
+
use Illuminate\Support\Facades\Gate;
|
|
9
10
|
use Illuminate\Support\Facades\RateLimiter;
|
|
10
11
|
use Illuminate\Support\Facades\Route;
|
|
11
12
|
use Illuminate\Support\ServiceProvider;
|
|
13
|
+
use Nevela\Laravel\Access\Access;
|
|
14
|
+
use Nevela\Laravel\Access\Permissions;
|
|
12
15
|
use Nevela\Laravel\Console\DevCommand;
|
|
13
16
|
use Nevela\Laravel\Console\GenerateCommand;
|
|
14
17
|
use Nevela\Laravel\Console\MakeResourceCommand;
|
|
@@ -18,6 +21,9 @@ use Nevela\Laravel\Console\StatusCommand;
|
|
|
18
21
|
use Nevela\Laravel\Console\UpdateCommand;
|
|
19
22
|
use Nevela\Laravel\Console\UserCommand;
|
|
20
23
|
use Nevela\Laravel\Console\VersionCommand;
|
|
24
|
+
use Nevela\Laravel\Http\Access\RolesAreSetUp;
|
|
25
|
+
use Nevela\Laravel\Http\Access\RolesController;
|
|
26
|
+
use Nevela\Laravel\Http\Access\UsersController;
|
|
21
27
|
use Nevela\Laravel\Http\FlareErrors;
|
|
22
28
|
use Nevela\Laravel\Http\Auth\AccountController;
|
|
23
29
|
use Nevela\Laravel\Http\Auth\SecurityController;
|
|
@@ -59,6 +65,12 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
59
65
|
}
|
|
60
66
|
});
|
|
61
67
|
|
|
68
|
+
// An ability shaped like a permission ("products.view") is answered from the
|
|
69
|
+
// person's roles. So `$user->can('products.view')`, `Gate::authorize(...)` and
|
|
70
|
+
// `@can` all work, in a policy or anywhere else. Any other ability is left to
|
|
71
|
+
// the app's own policies and gates.
|
|
72
|
+
Gate::before(fn ($user, $ability) => is_string($ability) && Permissions::isPermission($ability) ? Access::allows($user, $ability) : null);
|
|
73
|
+
|
|
62
74
|
if (! $this->app->routesAreCached()) {
|
|
63
75
|
$this->registerRoutes();
|
|
64
76
|
}
|
|
@@ -159,6 +171,23 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
159
171
|
'data' => array_map(fn ($name) => (array) Uploads::profile($name), array_keys((array) config('nevela.uploads.profiles', ['default' => []]))),
|
|
160
172
|
]))->name('profiles');
|
|
161
173
|
|
|
174
|
+
// Users and roles, for whoever holds the permissions to manage them.
|
|
175
|
+
Route::middleware(RolesAreSetUp::class)->group(function () {
|
|
176
|
+
Route::get('_nevela/users', [UsersController::class, 'index'])->name('users.index');
|
|
177
|
+
Route::post('_nevela/users', [UsersController::class, 'store'])->name('users.store');
|
|
178
|
+
Route::get('_nevela/users/{id}', [UsersController::class, 'show'])->name('users.show');
|
|
179
|
+
Route::patch('_nevela/users/{id}', [UsersController::class, 'update'])->name('users.update');
|
|
180
|
+
Route::delete('_nevela/users/{id}', [UsersController::class, 'destroy'])->name('users.destroy');
|
|
181
|
+
Route::delete('_nevela/users/{id}/sessions', [UsersController::class, 'revokeSessions'])->name('users.sessions.revoke');
|
|
182
|
+
|
|
183
|
+
Route::get('_nevela/permissions', [RolesController::class, 'catalog'])->name('permissions');
|
|
184
|
+
Route::get('_nevela/roles', [RolesController::class, 'index'])->name('roles.index');
|
|
185
|
+
Route::post('_nevela/roles', [RolesController::class, 'store'])->name('roles.store');
|
|
186
|
+
Route::get('_nevela/roles/{id}', [RolesController::class, 'show'])->name('roles.show');
|
|
187
|
+
Route::patch('_nevela/roles/{id}', [RolesController::class, 'update'])->name('roles.update');
|
|
188
|
+
Route::delete('_nevela/roles/{id}', [RolesController::class, 'destroy'])->name('roles.destroy');
|
|
189
|
+
});
|
|
190
|
+
|
|
162
191
|
// The descriptors, so the web app (and tooling) can check it matches the API.
|
|
163
192
|
Route::get('_nevela/resources', fn () => response()->json([
|
|
164
193
|
'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
|
|
@@ -64,7 +64,11 @@ export async function GET(request: NextRequest, context: Context) {
|
|
|
64
64
|
const value = request.headers.get(name);
|
|
65
65
|
if (value) conditional[name] = value;
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
// The API's answers are JSON, whatever the browser said it would accept. Asked that way,
|
|
68
|
+
// a request with no session is told 401; asked as a page, Laravel would try to send it
|
|
69
|
+
// to a sign-in route this app doesn't have. A file is asked for as the browser wants it.
|
|
70
|
+
const accept = isFile ? (request.headers.get("accept") ?? "*/*") : "application/json";
|
|
71
|
+
return forward(url, { method: "GET", headers: { Accept: accept, ...conditional } }, !isFile);
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
export async function PUT(request: NextRequest, context: Context) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { accessError, type ManagedRole, type ManagedUser } from "@/lib/access";
|
|
5
|
+
import { laravel } from "@/lib/laravel";
|
|
6
|
+
import { getSession } from "@/lib/session";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Changes to users and roles. Each one is a call to Laravel, which checks the person's
|
|
10
|
+
* permissions and the rules on top of them (nobody hands out more than they hold; the
|
|
11
|
+
* last administrator stays), and whose answer is passed back as it is.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type AccessResult<T = unknown> = { ok: true; data: T } | { ok: false; error: string; issues?: { path: string; message: string }[] };
|
|
15
|
+
|
|
16
|
+
async function send<T>(path: string, method: "POST" | "PATCH" | "DELETE", body?: unknown): Promise<AccessResult<T>> {
|
|
17
|
+
if (!(await getSession())) return { ok: false, error: "Sign in to continue." };
|
|
18
|
+
const response = await laravel(path, { method, body });
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const issues = (response.body as { issues?: { path: string; message: string }[] } | null)?.issues;
|
|
21
|
+
return { ok: false, error: accessError(response), ...(issues ? { issues } : {}) };
|
|
22
|
+
}
|
|
23
|
+
revalidatePath("/dashboard/access", "layout");
|
|
24
|
+
return { ok: true, data: response.body as T };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UserInput {
|
|
28
|
+
name?: string;
|
|
29
|
+
email?: string;
|
|
30
|
+
/** Left out, the password stays as it is. */
|
|
31
|
+
password?: string;
|
|
32
|
+
/** Role ids. */
|
|
33
|
+
roles?: string[];
|
|
34
|
+
active?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function createUserAction(input: UserInput): Promise<AccessResult<ManagedUser>> {
|
|
38
|
+
return send("_nevela/users", "POST", input);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function updateUserAction(id: string, input: UserInput): Promise<AccessResult<ManagedUser>> {
|
|
42
|
+
return send(`_nevela/users/${encodeURIComponent(id)}`, "PATCH", input);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function deleteUserAction(id: string): Promise<AccessResult<null>> {
|
|
46
|
+
return send(`_nevela/users/${encodeURIComponent(id)}`, "DELETE");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Sign someone out of every device. */
|
|
50
|
+
export async function signOutUserAction(id: string): Promise<AccessResult<{ revoked: number }>> {
|
|
51
|
+
return send(`_nevela/users/${encodeURIComponent(id)}/sessions`, "DELETE");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RoleInput {
|
|
55
|
+
name?: string;
|
|
56
|
+
description?: string | null;
|
|
57
|
+
grants?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function createRoleAction(input: RoleInput): Promise<AccessResult<ManagedRole>> {
|
|
61
|
+
return send("_nevela/roles", "POST", input);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function updateRoleAction(id: string, input: RoleInput): Promise<AccessResult<ManagedRole>> {
|
|
65
|
+
return send(`_nevela/roles/${encodeURIComponent(id)}`, "PATCH", input);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function deleteRoleAction(id: string): Promise<AccessResult<null>> {
|
|
69
|
+
return send(`_nevela/roles/${encodeURIComponent(id)}`, "DELETE");
|
|
70
|
+
}
|