create-nevela 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +2 -0
  2. package/index.mjs +24 -10
  3. package/package.json +1 -1
  4. package/template/laravel/config/nevela.php +17 -2
  5. package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
  6. package/template/laravel/src/Access/Access.php +168 -0
  7. package/template/laravel/src/Access/Permissions.php +271 -0
  8. package/template/laravel/src/Access/SampleUsers.php +81 -0
  9. package/template/laravel/src/Auth/Account.php +17 -1
  10. package/template/laravel/src/Console/SetupCommand.php +11 -2
  11. package/template/laravel/src/Console/UserCommand.php +151 -59
  12. package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
  13. package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
  14. package/template/laravel/src/Http/Access/RolesController.php +197 -0
  15. package/template/laravel/src/Http/Access/UsersController.php +382 -0
  16. package/template/laravel/src/Http/Auth/AccountController.php +5 -1
  17. package/template/laravel/src/Models/Role.php +73 -0
  18. package/template/laravel/src/Nevela.php +2 -1
  19. package/template/laravel/src/NevelaServiceProvider.php +29 -0
  20. package/template/web/app/api/[...path]/route.ts +5 -1
  21. package/template/web/app/dashboard/access/actions.ts +70 -0
  22. package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
  23. package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
  24. package/template/web/app/dashboard/access/roles/page.tsx +77 -0
  25. package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
  26. package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
  27. package/template/web/app/dashboard/access/users/page.tsx +158 -0
  28. package/template/web/app/dashboard/actions.ts +10 -9
  29. package/template/web/app/dashboard/import-actions.ts +6 -5
  30. package/template/web/app/dashboard/layout.tsx +5 -2
  31. package/template/web/app/dashboard/page.tsx +27 -4
  32. package/template/web/components/access/role-form.tsx +389 -0
  33. package/template/web/components/access/user-form.tsx +167 -0
  34. package/template/web/components/access/user-row-actions.tsx +117 -0
  35. package/template/web/components/access/users-toolbar.tsx +82 -0
  36. package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
  37. package/template/web/lib/access.ts +107 -0
  38. package/template/web/lib/dashboard.ts +51 -18
  39. package/template/web/lib/session.ts +11 -0
  40. package/template/web/package.json +1 -1
@@ -0,0 +1,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.4.3';
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
- return forward(url, { method: "GET", headers: { Accept: request.headers.get("accept") ?? "application/json", ...conditional } }, !isFile);
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
+ }