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
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ php nevela dev
|
|
|
28
28
|
|
|
29
29
|
Open http://localhost:3000/sign-in and sign in as `admin@example.com` with the password `password`. That starter account is in your local database only; add your own with `php artisan nevela:user`.
|
|
30
30
|
|
|
31
|
+
The app starts with an administrator and ten sample users (two editors, eight users), in your local database only, so its Users and Roles screens have something to show. Leave the samples out with `--no-sample-users`.
|
|
32
|
+
|
|
31
33
|
Add your first resource, from the same folder:
|
|
32
34
|
|
|
33
35
|
```sh
|
package/index.mjs
CHANGED
|
@@ -29,6 +29,10 @@ const DOCS = 'https://nevela-docs.vercel.app';
|
|
|
29
29
|
* The account every new app starts with, so there is something to sign in as straight
|
|
30
30
|
* away. It is created in the app's local database, never in code or a migration, so it
|
|
31
31
|
* does not follow the app to a server.
|
|
32
|
+
*
|
|
33
|
+
* It is the app's administrator. Ten sample users are made beside it, with the same
|
|
34
|
+
* password: two editors and eight users, so the Users screen, the roles and what each
|
|
35
|
+
* of them is allowed to see have something to show on the first run.
|
|
32
36
|
*/
|
|
33
37
|
const ADMIN = { name: 'Admin', email: 'admin@example.com', password: 'password' };
|
|
34
38
|
|
|
@@ -76,7 +80,8 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
|
76
80
|
${bold('Options')}
|
|
77
81
|
--pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
|
|
78
82
|
--no-install Don't install the dashboard's dependencies.
|
|
79
|
-
--no-user Don't create the starter admin account.
|
|
83
|
+
--no-user Don't create the starter admin account, or the sample users.
|
|
84
|
+
--no-sample-users Create the admin only, without the ten sample users.
|
|
80
85
|
--fast Leave out PHPUnit, Pint and Laravel's other development packages.
|
|
81
86
|
About a third quicker. Add them later: cd apps/api && composer install
|
|
82
87
|
--no-git Don't run git init.
|
|
@@ -188,7 +193,7 @@ async function step(label, work) {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
function parseArgs(argv) {
|
|
191
|
-
const options = { name: undefined, pm: undefined, install: true, user: true, git: true, yes: false, bundledPackage: false, devTools: true };
|
|
196
|
+
const options = { name: undefined, pm: undefined, install: true, user: true, samples: true, git: true, yes: false, bundledPackage: false, devTools: true };
|
|
192
197
|
for (let i = 0; i < argv.length; i++) {
|
|
193
198
|
const arg = argv[i];
|
|
194
199
|
if (arg === '-h' || arg === '--help') {
|
|
@@ -201,6 +206,7 @@ function parseArgs(argv) {
|
|
|
201
206
|
} else if (arg === '-y' || arg === '--yes') options.yes = true;
|
|
202
207
|
else if (arg === '--no-install') options.install = false;
|
|
203
208
|
else if (arg === '--no-user') options.user = false;
|
|
209
|
+
else if (arg === '--no-sample-users') options.samples = false;
|
|
204
210
|
else if (arg === '--no-git') options.git = false;
|
|
205
211
|
else if (arg === '--no-dev-tools' || arg === '--fast') options.devTools = false;
|
|
206
212
|
else if (arg === '--bundled-package') options.bundledPackage = true;
|
|
@@ -279,7 +285,7 @@ function write(file, contents) {
|
|
|
279
285
|
fs.writeFileSync(file, contents);
|
|
280
286
|
}
|
|
281
287
|
|
|
282
|
-
function writeRootFiles(root, { name, title, pm, admin }) {
|
|
288
|
+
function writeRootFiles(root, { name, title, pm, admin, samples }) {
|
|
283
289
|
write(path.join(root, 'package.json'), `${JSON.stringify({
|
|
284
290
|
name,
|
|
285
291
|
version: '0.1.0',
|
|
@@ -313,13 +319,15 @@ To check the app at any time (versions, migrations, users, and whether the dashb
|
|
|
313
319
|
php nevela status
|
|
314
320
|
\`\`\`
|
|
315
321
|
${admin ? `
|
|
316
|
-
The app starts with
|
|
322
|
+
The app starts with an administrator, in your local database only:
|
|
317
323
|
|
|
318
324
|
| | |
|
|
319
325
|
|---|---|
|
|
320
326
|
| Email | \`${admin.email}\` |
|
|
321
327
|
| Password | \`${admin.password}\` |
|
|
322
|
-
|
|
328
|
+
${samples ? `
|
|
329
|
+
Ten sample users are there as well, with the same password: two editors and eight users, one of them switched off. They are under **Users** in the dashboard, and what each role may do is under **Roles**. Delete them before real use.
|
|
330
|
+
` : ''}` : ''}
|
|
323
331
|
Everything else runs from this folder too, with \`php nevela\`:
|
|
324
332
|
|
|
325
333
|
\`\`\`sh
|
|
@@ -633,7 +641,7 @@ async function main() {
|
|
|
633
641
|
// `nevela upgrade` compares against these to tell the files you changed from the ones
|
|
634
642
|
// you didn't, so an update never overwrites your work.
|
|
635
643
|
write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
|
|
636
|
-
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
|
|
644
|
+
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples });
|
|
637
645
|
const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
|
|
638
646
|
|
|
639
647
|
const tokens = await step('Creating the Laravel app', async (progress) => {
|
|
@@ -689,13 +697,16 @@ async function main() {
|
|
|
689
697
|
});
|
|
690
698
|
|
|
691
699
|
let admin = false;
|
|
700
|
+
let samples = false;
|
|
692
701
|
await step('Setting up the database', () => {
|
|
693
702
|
// One artisan process for the app key, Sanctum's migration, the generated files, the
|
|
694
|
-
// migrations and the
|
|
695
|
-
|
|
703
|
+
// migrations, the starter account and the sample users: each separate call would
|
|
704
|
+
// start Laravel again.
|
|
705
|
+
const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`, ...(options.samples ? [] : ['--no-sample-users'])] : [];
|
|
696
706
|
const setup = run('php', ['artisan', 'nevela:setup', ...account], { cwd: api, allowFailure: true });
|
|
697
707
|
if (setup.ok) {
|
|
698
708
|
admin = account.length > 0;
|
|
709
|
+
samples = admin && options.samples;
|
|
699
710
|
return;
|
|
700
711
|
}
|
|
701
712
|
// 2: the app is set up and only the starter account could not be created.
|
|
@@ -707,7 +718,7 @@ async function main() {
|
|
|
707
718
|
run('php', ['artisan', 'vendor:publish', '--tag=sanctum-migrations', '--no-interaction'], { cwd: api });
|
|
708
719
|
run('php', ['artisan', 'nevela:generate'], { cwd: api });
|
|
709
720
|
run('php', ['artisan', 'migrate', '--force', '--no-interaction'], { cwd: api });
|
|
710
|
-
if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account], { cwd: api, allowFailure: true }).ok;
|
|
721
|
+
if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account.filter((arg) => arg !== '--no-sample-users')], { cwd: api, allowFailure: true }).ok;
|
|
711
722
|
});
|
|
712
723
|
|
|
713
724
|
let installed = false;
|
|
@@ -741,7 +752,10 @@ async function main() {
|
|
|
741
752
|
if (admin) {
|
|
742
753
|
console.log(`\n Email ${bold(ADMIN.email)}`);
|
|
743
754
|
console.log(` Password ${bold(ADMIN.password)}\n`);
|
|
744
|
-
console.log(` ${dim(`That account is in this app's local database only. Add your own: ${nevela} user`)}`);
|
|
755
|
+
console.log(` ${dim(`That account is the administrator, and is in this app's local database only. Add your own: ${nevela} user`)}`);
|
|
756
|
+
if (samples) {
|
|
757
|
+
console.log(` ${dim(`Ten sample users are there too, with the same password: 2 editors and 8 users. See them under Users, and what each may do under Roles.`)}`);
|
|
758
|
+
}
|
|
745
759
|
}
|
|
746
760
|
console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
|
|
747
761
|
console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
|
package/package.json
CHANGED
|
@@ -24,6 +24,18 @@ return [
|
|
|
24
24
|
'prefix' => 'api',
|
|
25
25
|
'middleware' => ['api', 'auth:sanctum'],
|
|
26
26
|
|
|
27
|
+
/*
|
|
28
|
+
| Permissions of your own, beside the ones every resource gets (products.view,
|
|
29
|
+
| products.create, products.edit, products.delete) and the built-in users.* and roles.*.
|
|
30
|
+
| They appear on the dashboard's Roles screen, and you check one anywhere in Laravel
|
|
31
|
+
| with `$user->can('reports.view')`. Actions are any of: create, view, edit, delete.
|
|
32
|
+
|
|
|
33
|
+
| 'permissions' => [
|
|
34
|
+
| 'reports' => ['name' => 'Reports', 'actions' => ['view']],
|
|
35
|
+
| ],
|
|
36
|
+
*/
|
|
37
|
+
'permissions' => [],
|
|
38
|
+
|
|
27
39
|
/*
|
|
28
40
|
| Signing in. Every endpoint is under {prefix}/auth. Email and password is always
|
|
29
41
|
| available; the rest can be switched off here. A method that is off is refused by the
|
|
@@ -74,8 +86,11 @@ return [
|
|
|
74
86
|
*/
|
|
75
87
|
'attempts_per_address' => 300,
|
|
76
88
|
|
|
77
|
-
/*
|
|
78
|
-
|
|
89
|
+
/*
|
|
90
|
+
| The role a self-registered account starts with. USER allows nothing beyond the
|
|
91
|
+
| person's own account, which is what makes open sign-up safe. Null gives no role.
|
|
92
|
+
*/
|
|
93
|
+
'default_role' => 'USER',
|
|
79
94
|
|
|
80
95
|
/*
|
|
81
96
|
| Where the dashboard is, for the links in emails and for passkeys, which are tied
|
package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\DB;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
use Illuminate\Support\Str;
|
|
8
|
+
use Nevela\Laravel\Models\Role;
|
|
9
|
+
|
|
10
|
+
// Roles and permissions, and switching an account off.
|
|
11
|
+
//
|
|
12
|
+
// Until now every signed-in user could do everything. So that upgrading an app changes
|
|
13
|
+
// nothing about who can do what, every user it already has is made an ADMIN here. Give
|
|
14
|
+
// them narrower roles afterwards, from the dashboard's Users screen.
|
|
15
|
+
return new class extends Migration
|
|
16
|
+
{
|
|
17
|
+
public function up(): void
|
|
18
|
+
{
|
|
19
|
+
Schema::create('nevela_roles', function (Blueprint $table) {
|
|
20
|
+
$table->uuid('id')->primary();
|
|
21
|
+
$table->string('name', 80)->unique();
|
|
22
|
+
$table->string('description', 500)->nullable();
|
|
23
|
+
// A JSON list of grants: permissions, and patterns such as "products.*".
|
|
24
|
+
$table->text('grants');
|
|
25
|
+
// One the app depends on: it can be edited, and not renamed or deleted.
|
|
26
|
+
$table->boolean('is_system')->default(false);
|
|
27
|
+
$table->timestamps();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
Schema::create('nevela_role_user', function (Blueprint $table) {
|
|
31
|
+
$table->foreignUuid('role_id')->constrained('nevela_roles')->cascadeOnDelete();
|
|
32
|
+
// A string, so it holds a numeric id or a UUID, whichever the app's users have.
|
|
33
|
+
$table->string('user_id');
|
|
34
|
+
$table->primary(['role_id', 'user_id']);
|
|
35
|
+
$table->index('user_id');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
$model = config('auth.providers.users.model');
|
|
39
|
+
$user = is_string($model) && class_exists($model) ? new $model : null;
|
|
40
|
+
$users = $user?->getTable() ?? 'users';
|
|
41
|
+
$key = $user?->getKeyName() ?? 'id';
|
|
42
|
+
|
|
43
|
+
if (Schema::hasTable($users) && ! Schema::hasColumn($users, 'active')) {
|
|
44
|
+
Schema::table($users, function (Blueprint $table) {
|
|
45
|
+
// False: the account can't sign in, and is signed out everywhere.
|
|
46
|
+
$table->boolean('active')->default(true);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
$now = now();
|
|
51
|
+
$admin = null;
|
|
52
|
+
foreach (Role::defaults() as $role) {
|
|
53
|
+
$id = (string) Str::uuid();
|
|
54
|
+
DB::table('nevela_roles')->insert([
|
|
55
|
+
'id' => $id,
|
|
56
|
+
'name' => $role['name'],
|
|
57
|
+
'description' => $role['description'],
|
|
58
|
+
'grants' => json_encode($role['grants']),
|
|
59
|
+
'is_system' => true,
|
|
60
|
+
'created_at' => $now,
|
|
61
|
+
'updated_at' => $now,
|
|
62
|
+
]);
|
|
63
|
+
$admin ??= $role['name'] === Role::ADMIN ? $id : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ($admin !== null && Schema::hasTable($users)) {
|
|
67
|
+
DB::table($users)->orderBy($key)->select($key)->chunk(500, function ($rows) use ($admin, $key) {
|
|
68
|
+
DB::table('nevela_role_user')->insert($rows->map(fn ($row) => ['role_id' => $admin, 'user_id' => (string) $row->{$key}])->all());
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public function down(): void
|
|
74
|
+
{
|
|
75
|
+
// The "active" column stays: an app may have had one of its own before this ran.
|
|
76
|
+
Schema::dropIfExists('nevela_role_user');
|
|
77
|
+
Schema::dropIfExists('nevela_roles');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Access;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\DB;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
use Nevela\Laravel\Auth\Account;
|
|
8
|
+
use Nevela\Laravel\Models\Role;
|
|
9
|
+
use WeakMap;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Who holds which roles, and what those come to.
|
|
13
|
+
*
|
|
14
|
+
* Laravel's gate asks here for any ability shaped like a permission ("products.view"), so
|
|
15
|
+
* `$user->can('products.view')`, `Gate::authorize('users.edit')` and `@can` all work, and a
|
|
16
|
+
* generated policy is one line per action.
|
|
17
|
+
*/
|
|
18
|
+
final class Access
|
|
19
|
+
{
|
|
20
|
+
/** @var WeakMap<object, list<string>>|null Grants per user object, so a request asks the database once. */
|
|
21
|
+
private static ?WeakMap $grants = null;
|
|
22
|
+
|
|
23
|
+
private static bool $ready = false;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether the roles tables exist. Before an upgraded app has run its migrations they
|
|
27
|
+
* don't, and until then everyone may do everything, as they could before.
|
|
28
|
+
*/
|
|
29
|
+
public static function ready(): bool
|
|
30
|
+
{
|
|
31
|
+
// Only "yes" is remembered: the tables can appear later in the same process.
|
|
32
|
+
return self::$ready = self::$ready || Schema::hasTable('nevela_roles');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The grants a user holds, across all their roles. Patterns are kept as they are:
|
|
37
|
+
* give the result to Permissions::granted(), and don't compare strings.
|
|
38
|
+
*
|
|
39
|
+
* @return list<string>
|
|
40
|
+
*/
|
|
41
|
+
public static function grantsFor(mixed $user): array
|
|
42
|
+
{
|
|
43
|
+
if (! is_object($user)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
if (! self::ready()) {
|
|
47
|
+
return ['*'];
|
|
48
|
+
}
|
|
49
|
+
self::$grants ??= new WeakMap;
|
|
50
|
+
if (isset(self::$grants[$user])) {
|
|
51
|
+
return self::$grants[$user];
|
|
52
|
+
}
|
|
53
|
+
if (! self::active($user)) {
|
|
54
|
+
return self::$grants[$user] = [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
$grants = [];
|
|
58
|
+
foreach (self::rolesOf($user) as $role) {
|
|
59
|
+
array_push($grants, ...$role->grants);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return self::$grants[$user] = array_values(array_unique($grants));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public static function allows(mixed $user, string $permission): bool
|
|
66
|
+
{
|
|
67
|
+
return Permissions::granted(self::grantsFor($user), $permission);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An administrator: someone whose roles together come to everything. */
|
|
71
|
+
public static function isAdmin(mixed $user): bool
|
|
72
|
+
{
|
|
73
|
+
return Permissions::hasAll(self::grantsFor($user));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** False when the account has been switched off. An app with no such column has no such thing. */
|
|
77
|
+
public static function active(mixed $user): bool
|
|
78
|
+
{
|
|
79
|
+
$attributes = method_exists($user, 'getAttributes') ? $user->getAttributes() : [];
|
|
80
|
+
|
|
81
|
+
// Only an explicit "no" switches an account off: a column with nothing in it doesn't.
|
|
82
|
+
return ! isset($attributes['active']) || (bool) $attributes['active'];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @return \Illuminate\Database\Eloquent\Collection<int, Role> */
|
|
86
|
+
public static function rolesOf(mixed $user)
|
|
87
|
+
{
|
|
88
|
+
return Role::query()
|
|
89
|
+
->whereIn('id', DB::table('nevela_role_user')->where('user_id', (string) $user->getAuthIdentifier())->select('role_id'))
|
|
90
|
+
->orderBy('name')
|
|
91
|
+
->get();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Replace a user's roles.
|
|
96
|
+
*
|
|
97
|
+
* @param list<string> $roleIds
|
|
98
|
+
*/
|
|
99
|
+
public static function assign(mixed $user, array $roleIds): void
|
|
100
|
+
{
|
|
101
|
+
$id = (string) $user->getAuthIdentifier();
|
|
102
|
+
DB::transaction(function () use ($id, $roleIds) {
|
|
103
|
+
DB::table('nevela_role_user')->where('user_id', $id)->delete();
|
|
104
|
+
$rows = array_map(fn (string $role) => ['role_id' => $role, 'user_id' => $id], array_values(array_unique($roleIds)));
|
|
105
|
+
if ($rows !== []) {
|
|
106
|
+
DB::table('nevela_role_user')->insert($rows);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
self::forget();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Give a user one role by name, on top of what they have. False when there is no such role. */
|
|
113
|
+
public static function grant(mixed $user, string $roleName): bool
|
|
114
|
+
{
|
|
115
|
+
if (! self::ready()) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
$role = Role::query()->whereRaw('lower(name) = ?', [mb_strtolower($roleName)])->first();
|
|
119
|
+
if (! $role) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
DB::table('nevela_role_user')->insertOrIgnore(['role_id' => $role->id, 'user_id' => (string) $user->getAuthIdentifier()]);
|
|
123
|
+
self::forget();
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How many administrators can still sign in, leaving out `$except`.
|
|
130
|
+
* The last one can't be removed, switched off or given a lesser role.
|
|
131
|
+
*
|
|
132
|
+
* Counted here as holding one role that comes to everything (ADMIN, or one like it).
|
|
133
|
+
* Someone whose lesser roles happen to add up to everything isn't relied on to be the
|
|
134
|
+
* app's last way in.
|
|
135
|
+
*/
|
|
136
|
+
public static function otherAdmins(mixed $except = null): int
|
|
137
|
+
{
|
|
138
|
+
$model = Account::model();
|
|
139
|
+
$instance = new $model;
|
|
140
|
+
$table = $instance->getTable();
|
|
141
|
+
$key = $instance->getKeyName();
|
|
142
|
+
$hasActive = Schema::hasColumn($table, 'active');
|
|
143
|
+
|
|
144
|
+
$all =Role::query()->get()->filter(fn (Role $role) => Permissions::hasAll($role->grants))->pluck('id')->all();
|
|
145
|
+
$ids = DB::table('nevela_role_user')->whereIn('role_id', $all)->distinct()->pluck('user_id')->all();
|
|
146
|
+
if ($except !== null) {
|
|
147
|
+
$ids = array_values(array_diff($ids, [(string) $except->getAuthIdentifier()]));
|
|
148
|
+
}
|
|
149
|
+
if ($ids === []) {
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return $model::query()->whereIn($key, $ids)->when($hasActive, fn ($query) => $query->where('active', true))->count();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Forget what has been worked out, after roles or who holds them change. */
|
|
157
|
+
public static function forget(): void
|
|
158
|
+
{
|
|
159
|
+
self::$grants = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** For tests that build and drop the tables within one process. */
|
|
163
|
+
public static function reset(): void
|
|
164
|
+
{
|
|
165
|
+
self::$grants = null;
|
|
166
|
+
self::$ready = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Access;
|
|
4
|
+
|
|
5
|
+
use Nevela\Laravel\Nevela;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every permission this app understands, and the rules for matching a grant against one.
|
|
9
|
+
*
|
|
10
|
+
* A permission is `feature.action`: "products.view", "users.edit". The feature is a
|
|
11
|
+
* resource's table, or one of the built-in ones (users, roles), or one the app adds in
|
|
12
|
+
* config/nevela.php. The action is one of create, view, edit, delete.
|
|
13
|
+
*
|
|
14
|
+
* A role holds grants, and a grant may be a pattern:
|
|
15
|
+
*
|
|
16
|
+
* * everything
|
|
17
|
+
* products.* every action on products
|
|
18
|
+
* *.view viewing everything
|
|
19
|
+
* @resources.* every action on every resource, including ones added later
|
|
20
|
+
* @resources.view viewing every resource
|
|
21
|
+
*
|
|
22
|
+
* The model, the names and the matching rules are Grit's (internal/authz), so a role means
|
|
23
|
+
* the same thing in both.
|
|
24
|
+
*/
|
|
25
|
+
final class Permissions
|
|
26
|
+
{
|
|
27
|
+
public const ACTIONS = ['create', 'view', 'edit', 'delete'];
|
|
28
|
+
|
|
29
|
+
/** @var list<array{key: string, name: string, groups: list<array{key: string, name: string, features: list<array{key: string, name: string, actions: list<string>}>}>}>|null */
|
|
30
|
+
private static ?array $catalog = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The catalog, shaped for the roles screen: modules, their groups, their features.
|
|
34
|
+
* Only the feature and the action appear in a permission; the rest is for reading.
|
|
35
|
+
*
|
|
36
|
+
* @return list<array{key: string, name: string, groups: list<array{key: string, name: string, features: list<array{key: string, name: string, actions: list<string>}>}>}>
|
|
37
|
+
*/
|
|
38
|
+
public static function catalog(): array
|
|
39
|
+
{
|
|
40
|
+
if (self::$catalog !== null) {
|
|
41
|
+
return self::$catalog;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$taken = ['users' => true, 'roles' => true];
|
|
45
|
+
$modules = [[
|
|
46
|
+
'key' => 'access',
|
|
47
|
+
'name' => 'Access',
|
|
48
|
+
'groups' => [[
|
|
49
|
+
'key' => 'identity',
|
|
50
|
+
'name' => 'Identity',
|
|
51
|
+
'features' => [
|
|
52
|
+
['key' => 'users', 'name' => 'Users', 'actions' => self::ACTIONS],
|
|
53
|
+
['key' => 'roles', 'name' => 'Roles and permissions', 'actions' => self::ACTIONS],
|
|
54
|
+
],
|
|
55
|
+
]],
|
|
56
|
+
]];
|
|
57
|
+
|
|
58
|
+
// One feature per resource, under the heading it has in the sidebar.
|
|
59
|
+
$groups = [];
|
|
60
|
+
foreach (Nevela::all() as $descriptor) {
|
|
61
|
+
if (isset($taken[$descriptor->table])) {
|
|
62
|
+
continue; // a resource on the users or roles table is governed by the built-in feature
|
|
63
|
+
}
|
|
64
|
+
$taken[$descriptor->table] = true;
|
|
65
|
+
$heading = trim((string) ($descriptor->group ?? '')) ?: 'Resources';
|
|
66
|
+
$groups[$heading] ??= ['key' => self::slug($heading), 'name' => $heading, 'features' => []];
|
|
67
|
+
$groups[$heading]['features'][] = ['key' => $descriptor->table, 'name' => $descriptor->pluralLabel, 'actions' => self::ACTIONS];
|
|
68
|
+
}
|
|
69
|
+
if ($groups !== []) {
|
|
70
|
+
$modules[] = ['key' => 'resources', 'name' => 'Resources', 'groups' => array_values($groups)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The app's own: config('nevela.permissions') = ['reports' => ['name' => 'Reports', 'actions' => ['view']]].
|
|
74
|
+
$custom = [];
|
|
75
|
+
foreach ((array) config('nevela.permissions', []) as $key => $feature) {
|
|
76
|
+
$key = is_string($key) ? $key : (string) ($feature['key'] ?? '');
|
|
77
|
+
if (! preg_match('/^[a-z][a-z0-9_]*$/', $key) || isset($taken[$key])) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
$taken[$key] = true;
|
|
81
|
+
$actions = array_values(array_intersect(self::ACTIONS, (array) ($feature['actions'] ?? self::ACTIONS)));
|
|
82
|
+
$custom[] = ['key' => $key, 'name' => (string) ($feature['name'] ?? ucfirst(str_replace('_', ' ', $key))), 'actions' => $actions ?: self::ACTIONS];
|
|
83
|
+
}
|
|
84
|
+
if ($custom !== []) {
|
|
85
|
+
$modules[] = ['key' => 'app', 'name' => 'App', 'groups' => [['key' => 'app', 'name' => 'App', 'features' => $custom]]];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return self::$catalog = $modules;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Forget the catalog, after the descriptors or the config change. */
|
|
92
|
+
public static function forget(): void
|
|
93
|
+
{
|
|
94
|
+
self::$catalog = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every permission in the catalog, sorted.
|
|
99
|
+
*
|
|
100
|
+
* @return list<string>
|
|
101
|
+
*/
|
|
102
|
+
public static function keys(): array
|
|
103
|
+
{
|
|
104
|
+
$keys = [];
|
|
105
|
+
foreach (self::catalog() as $module) {
|
|
106
|
+
foreach ($module['groups'] as $group) {
|
|
107
|
+
foreach ($group['features'] as $feature) {
|
|
108
|
+
foreach ($feature['actions'] as $action) {
|
|
109
|
+
$keys[] = "{$feature['key']}.{$action}";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
sort($keys);
|
|
115
|
+
|
|
116
|
+
return $keys;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether `ability` has the shape of a permission, so the gate answers for it. */
|
|
120
|
+
public static function isPermission(string $ability): bool
|
|
121
|
+
{
|
|
122
|
+
return (bool) preg_match('/^[a-z][a-z0-9_]*\.(create|view|edit|delete)$/', $ability);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether any of the grants allows `key`.
|
|
127
|
+
*
|
|
128
|
+
* @param list<string> $grants
|
|
129
|
+
*/
|
|
130
|
+
public static function granted(array $grants, string $key): bool
|
|
131
|
+
{
|
|
132
|
+
foreach ($grants as $grant) {
|
|
133
|
+
if (is_string($grant) && self::matches($grant, $key)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether one grant covers one permission.
|
|
143
|
+
*
|
|
144
|
+
* A pattern is checked to its end: "products.*" covers "products.view", and "*.view"
|
|
145
|
+
* covers "products.view" and nothing else of products. Stopping at the first "*"
|
|
146
|
+
* would turn "*.view" into "everything".
|
|
147
|
+
*/
|
|
148
|
+
public static function matches(string $pattern, string $key): bool
|
|
149
|
+
{
|
|
150
|
+
if ($pattern === '*' || $pattern === $key) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
$p = explode('.', $pattern);
|
|
154
|
+
$k = explode('.', $key);
|
|
155
|
+
if (count($p) !== 2 || count($k) !== 2) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
[$feature, $action] = $p;
|
|
159
|
+
|
|
160
|
+
if (str_starts_with($feature, '@')) {
|
|
161
|
+
if (! in_array($k[0], self::featuresOf(substr($feature, 1)), true)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
} elseif ($feature !== '*' && $feature !== $k[0]) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return $action === '*' || $action === $k[1];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The permissions a set of grants comes to, patterns resolved against the catalog.
|
|
173
|
+
* Clients are given this, so nothing outside this class has to match a pattern.
|
|
174
|
+
*
|
|
175
|
+
* @param list<string> $grants
|
|
176
|
+
* @return list<string>
|
|
177
|
+
*/
|
|
178
|
+
public static function expand(array $grants): array
|
|
179
|
+
{
|
|
180
|
+
return array_values(array_filter(self::keys(), fn (string $key) => self::granted($grants, $key)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether the grants come to everything there is.
|
|
185
|
+
*
|
|
186
|
+
* @param list<string> $grants
|
|
187
|
+
*/
|
|
188
|
+
public static function hasAll(array $grants): bool
|
|
189
|
+
{
|
|
190
|
+
return in_array('*', $grants, true) || count(self::expand($grants)) === count(self::keys());
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The requested grants that `held` does not cover. A pattern is covered only when
|
|
195
|
+
* everything it comes to is; one that comes to nothing is not.
|
|
196
|
+
*
|
|
197
|
+
* This is the ceiling: nobody hands out more than they hold.
|
|
198
|
+
*
|
|
199
|
+
* @param list<string> $held
|
|
200
|
+
* @param list<string> $requested
|
|
201
|
+
* @return list<string>
|
|
202
|
+
*/
|
|
203
|
+
public static function beyond(array $held, array $requested): array
|
|
204
|
+
{
|
|
205
|
+
if (in_array('*', $held, true)) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
$beyond = [];
|
|
209
|
+
foreach ($requested as $grant) {
|
|
210
|
+
if ($grant === '*') {
|
|
211
|
+
$beyond[] = $grant;
|
|
212
|
+
} elseif (str_contains($grant, '*') || str_starts_with($grant, '@')) {
|
|
213
|
+
$keys = self::expand([$grant]);
|
|
214
|
+
$covered = $keys !== [];
|
|
215
|
+
foreach ($keys as $key) {
|
|
216
|
+
$covered = $covered && self::granted($held, $key);
|
|
217
|
+
}
|
|
218
|
+
if (! $covered) {
|
|
219
|
+
$beyond[] = $grant;
|
|
220
|
+
}
|
|
221
|
+
} elseif (! self::granted($held, $grant)) {
|
|
222
|
+
$beyond[] = $grant;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return $beyond;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Whether a grant is one the catalog understands: "*", a permission in it, or a pattern
|
|
231
|
+
* that comes to at least one. A module pattern is accepted for a module with nothing in
|
|
232
|
+
* it yet, because its point is to cover what is added later.
|
|
233
|
+
*/
|
|
234
|
+
public static function understood(string $grant): bool
|
|
235
|
+
{
|
|
236
|
+
if ($grant === '*') {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
if (! preg_match('/^(\*|@?[a-z][a-z0-9_]*)\.(\*|create|view|edit|delete)$/', $grant)) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
if (str_starts_with($grant, '@')) {
|
|
243
|
+
return in_array(substr(explode('.', $grant)[0], 1), ['access', 'resources', 'app'], true);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return self::expand([$grant]) !== [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @return list<string> */
|
|
250
|
+
private static function featuresOf(string $module): array
|
|
251
|
+
{
|
|
252
|
+
$features = [];
|
|
253
|
+
foreach (self::catalog() as $entry) {
|
|
254
|
+
if ($entry['key'] !== $module) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
foreach ($entry['groups'] as $group) {
|
|
258
|
+
foreach ($group['features'] as $feature) {
|
|
259
|
+
$features[] = $feature['key'];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return $features;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private static function slug(string $heading): string
|
|
268
|
+
{
|
|
269
|
+
return trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($heading)), '-') ?: 'resources';
|
|
270
|
+
}
|
|
271
|
+
}
|