create-nevela 0.4.3 → 0.5.1
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 +18 -15
- package/index.mjs +38 -14
- package/package.json +1 -1
- package/template/laravel/README.md +6 -4
- 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/app/page.tsx +3 -9
- 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,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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Access;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\Hash;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
use Nevela\Laravel\Auth\Account;
|
|
8
|
+
use Nevela\Laravel\Models\Role;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ten people for a new app, so its Users screen, its roles and its permissions have
|
|
12
|
+
* something to show from the first run: two editors and eight users, one switched off.
|
|
13
|
+
*
|
|
14
|
+
* Like the starter admin they are made in the app's local database by the installer,
|
|
15
|
+
* never by a migration, so they don't follow the app to a server. They all share one
|
|
16
|
+
* password, which is printed when they are made, so they are never made in production.
|
|
17
|
+
*/
|
|
18
|
+
final class SampleUsers
|
|
19
|
+
{
|
|
20
|
+
/** Name, role, and whether the account is switched on. The addresses are at example.com, which nobody owns. */
|
|
21
|
+
public const PEOPLE = [
|
|
22
|
+
['Amara Okafor', Role::EDITOR, true],
|
|
23
|
+
['Daniel Kim', Role::EDITOR, true],
|
|
24
|
+
['Sofia Martinez', Role::USER, true],
|
|
25
|
+
['James Mwangi', Role::USER, true],
|
|
26
|
+
['Priya Nair', Role::USER, true],
|
|
27
|
+
['Lucas Silva', Role::USER, true],
|
|
28
|
+
['Hannah Schmidt', Role::USER, true],
|
|
29
|
+
['Omar Haddad', Role::USER, true],
|
|
30
|
+
['Grace Nakato', Role::USER, true],
|
|
31
|
+
['Noah Williams', Role::USER, false],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** Ten accounts with one password that is written down in the docs have no place on a server. */
|
|
35
|
+
public static function allowed(): bool
|
|
36
|
+
{
|
|
37
|
+
return ! app()->environment('production');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create the first `count` of them who don't exist yet. None, in production.
|
|
42
|
+
*
|
|
43
|
+
* @return list<array{name: string, email: string, role: string, active: bool}> Those created
|
|
44
|
+
*/
|
|
45
|
+
public static function create(string $password, int $count = 10): array
|
|
46
|
+
{
|
|
47
|
+
if (! self::allowed() || $password === '') {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
$model = Account::model();
|
|
51
|
+
$table = (new $model)->getTable();
|
|
52
|
+
$hash = Hash::make($password);
|
|
53
|
+
$created = [];
|
|
54
|
+
|
|
55
|
+
foreach (array_slice(self::PEOPLE, 0, max(0, $count)) as [$name, $role, $active]) {
|
|
56
|
+
$email = self::email($name);
|
|
57
|
+
if (Account::findByEmail($email)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
$attributes = ['name' => $name, 'email' => $email, 'password' => $hash];
|
|
61
|
+
if (Schema::hasColumn($table, 'email_verified_at')) {
|
|
62
|
+
$attributes['email_verified_at'] = now();
|
|
63
|
+
}
|
|
64
|
+
if (Schema::hasColumn($table, 'active')) {
|
|
65
|
+
$attributes['active'] = $active;
|
|
66
|
+
}
|
|
67
|
+
$user = (new $model)->forceFill($attributes);
|
|
68
|
+
$user->save();
|
|
69
|
+
Access::grant($user, $role);
|
|
70
|
+
$created[] = ['name' => $name, 'email' => $email, 'role' => $role, 'active' => $active];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return $created;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** "Amara Okafor" → amara.okafor@example.com */
|
|
77
|
+
public static function email(string $name): string
|
|
78
|
+
{
|
|
79
|
+
return strtolower(str_replace(' ', '.', $name)).'@example.com';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
namespace Nevela\Laravel\Auth;
|
|
4
4
|
|
|
5
|
+
use Illuminate\Http\Exceptions\HttpResponseException;
|
|
5
6
|
use Illuminate\Http\Request;
|
|
6
7
|
use Illuminate\Support\Facades\DB;
|
|
7
8
|
use Illuminate\Support\Facades\Schema;
|
|
8
9
|
use Illuminate\Validation\Rules\Password;
|
|
10
|
+
use Nevela\Laravel\Access\Access;
|
|
11
|
+
use Nevela\Laravel\Access\Permissions;
|
|
9
12
|
use Nevela\Laravel\Media\Uploads;
|
|
10
13
|
use Nevela\Laravel\Models\TwoFactor;
|
|
11
14
|
use RuntimeException;
|
|
@@ -35,12 +38,20 @@ final class Account
|
|
|
35
38
|
{
|
|
36
39
|
$avatar = $user->avatar ?? null;
|
|
37
40
|
$file = is_string($avatar) && $avatar !== '' ? Uploads::ref($avatar) : null;
|
|
41
|
+
$roles = Access::ready() ? Access::rolesOf($user)->pluck('name')->all() : [];
|
|
42
|
+
$grants = Access::grantsFor($user);
|
|
38
43
|
|
|
39
44
|
return [
|
|
40
45
|
'id' => (string) $user->getAuthIdentifier(),
|
|
41
46
|
'name' => $user->name ?? null,
|
|
42
47
|
'email' => $user->email ?? null,
|
|
43
|
-
|
|
48
|
+
// The first role, for code written when an account had one; "roles" has them all.
|
|
49
|
+
'role' => $roles[0] ?? ($user->role ?? null),
|
|
50
|
+
'roles' => $roles,
|
|
51
|
+
// Everything they may do, patterns already resolved, so a client only looks a
|
|
52
|
+
// permission up. Laravel decides each request for itself whatever a client shows.
|
|
53
|
+
'permissions' => Permissions::expand($grants),
|
|
54
|
+
'isAdmin' => Permissions::hasAll($grants),
|
|
44
55
|
'emailVerified' => ($user->email_verified_at ?? null) !== null,
|
|
45
56
|
'twoFactorEnabled' => self::twoFactor($user)?->enabled_at !== null,
|
|
46
57
|
// The key is what is stored; "image" is the small rendition, ready for an <img>.
|
|
@@ -62,6 +73,11 @@ final class Account
|
|
|
62
73
|
*/
|
|
63
74
|
public static function signIn(mixed $user, Request $request): array
|
|
64
75
|
{
|
|
76
|
+
// Every way of signing in ends here, so this is the one place a switched-off
|
|
77
|
+
// account is turned away, whichever way it came.
|
|
78
|
+
if (! Access::active($user)) {
|
|
79
|
+
throw new HttpResponseException(response()->json(['error' => 'This account has been switched off. Ask an administrator.', 'code' => 'ACCOUNT_DISABLED'], 403));
|
|
80
|
+
}
|
|
65
81
|
if (! method_exists($user, 'createToken')) {
|
|
66
82
|
throw new RuntimeException('Add Laravel\Sanctum\HasApiTokens to '.self::model().' to issue Nevela tokens.');
|
|
67
83
|
}
|
|
@@ -15,9 +15,10 @@ final class SetupCommand extends Command
|
|
|
15
15
|
protected $signature = 'nevela:setup
|
|
16
16
|
{--name= : Name for the starter account}
|
|
17
17
|
{--email= : Email for the starter account. Leave out to create no account}
|
|
18
|
-
{--password= : Password for the starter account}
|
|
18
|
+
{--password= : Password for the starter account}
|
|
19
|
+
{--no-sample-users : Create the starter account only, without the ten sample users}';
|
|
19
20
|
|
|
20
|
-
protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations
|
|
21
|
+
protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations, a starter account and sample users';
|
|
21
22
|
|
|
22
23
|
protected $hidden = true;
|
|
23
24
|
|
|
@@ -39,6 +40,7 @@ final class SetupCommand extends Command
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
if ($this->option('email')) {
|
|
43
|
+
// The first account in an app is its administrator.
|
|
42
44
|
$status = $this->call('nevela:user', [
|
|
43
45
|
'--name' => $this->option('name') ?: 'Admin',
|
|
44
46
|
'--email' => $this->option('email'),
|
|
@@ -48,6 +50,13 @@ final class SetupCommand extends Command
|
|
|
48
50
|
// The app itself is fine; the installer says how to add an account by hand.
|
|
49
51
|
return 2;
|
|
50
52
|
}
|
|
53
|
+
// Ten more people, with the same password, so the Users screen and the roles
|
|
54
|
+
// have something in them. Only with a password that was given here: one typed
|
|
55
|
+
// at a prompt isn't known to this command. Never in production (the command
|
|
56
|
+
// refuses), and missing them is no reason to call the setup failed.
|
|
57
|
+
if (! $this->option('no-sample-users') && $this->option('password')) {
|
|
58
|
+
$this->callSilently('nevela:user', ['--sample' => true, '--password' => $this->option('password')]);
|
|
59
|
+
}
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
return self::SUCCESS;
|
|
@@ -1,59 +1,151 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
namespace Nevela\Laravel\Console;
|
|
4
|
-
|
|
5
|
-
use Illuminate\Console\Command;
|
|
6
|
-
use Illuminate\Support\Facades\Hash;
|
|
7
|
-
use Illuminate\Support\Facades\
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
protected $
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
$
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Illuminate\Support\Facades\Hash;
|
|
7
|
+
use Illuminate\Support\Facades\Schema;
|
|
8
|
+
use Illuminate\Support\Facades\Validator;
|
|
9
|
+
use Nevela\Laravel\Access\Access;
|
|
10
|
+
use Nevela\Laravel\Access\SampleUsers;
|
|
11
|
+
use Nevela\Laravel\Auth\Account;
|
|
12
|
+
use Nevela\Laravel\Models\Role;
|
|
13
|
+
|
|
14
|
+
final class UserCommand extends Command
|
|
15
|
+
{
|
|
16
|
+
protected $signature = 'nevela:user
|
|
17
|
+
{--name= : The person\'s name}
|
|
18
|
+
{--email= : The address they sign in with}
|
|
19
|
+
{--password= : Their password. Leave out to be asked for it without it showing}
|
|
20
|
+
{--role= : Their role: ADMIN, EDITOR, USER or one you have made. The first account is an ADMIN}
|
|
21
|
+
{--sample : Create the ten sample users instead: two editors and eight users}';
|
|
22
|
+
|
|
23
|
+
protected $description = 'Create a user who can sign in to the dashboard';
|
|
24
|
+
|
|
25
|
+
public function handle(): int
|
|
26
|
+
{
|
|
27
|
+
$model = config('auth.providers.users.model');
|
|
28
|
+
if (! is_string($model) || ! class_exists($model)) {
|
|
29
|
+
$this->components->error('No user model is configured in config/auth.php (auth.providers.users.model).');
|
|
30
|
+
|
|
31
|
+
return self::FAILURE;
|
|
32
|
+
}
|
|
33
|
+
if ($this->option('sample')) {
|
|
34
|
+
return $this->sample();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
$asked = $this->option('email') === null;
|
|
38
|
+
$input = [
|
|
39
|
+
'name' => $this->option('name') ?? $this->ask('Name'),
|
|
40
|
+
// As it will be stored, so that what is checked is what is saved.
|
|
41
|
+
'email' => mb_strtolower(trim((string) ($this->option('email') ?? $this->ask('Email')))),
|
|
42
|
+
'password' => $this->option('password') ?? $this->secret('Password (at least 8 characters)'),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
$table = (new $model)->getTable();
|
|
46
|
+
$validator = Validator::make($input, [
|
|
47
|
+
'name' => ['required', 'string', 'max:255'],
|
|
48
|
+
// Compared without regard to capitals, whatever the database does: Ada@ and ada@ are one person.
|
|
49
|
+
'email' => ['required', 'email', 'max:255', function (string $attribute, mixed $value, \Closure $fail) {
|
|
50
|
+
if (is_string($value) && Account::findByEmail($value)) {
|
|
51
|
+
$fail('Someone already has this email.');
|
|
52
|
+
}
|
|
53
|
+
}],
|
|
54
|
+
'password' => ['required', 'string', 'min:8'],
|
|
55
|
+
]);
|
|
56
|
+
if ($validator->fails()) {
|
|
57
|
+
foreach ($validator->errors()->all() as $message) {
|
|
58
|
+
$this->components->error($message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return self::FAILURE;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
$role = $this->role($asked);
|
|
65
|
+
if ($role === false) {
|
|
66
|
+
return self::FAILURE;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// forceFill: this is the operator creating an account, not a form to guard.
|
|
70
|
+
$attributes = ['name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password'])];
|
|
71
|
+
if (Schema::hasColumn($table, 'email_verified_at')) {
|
|
72
|
+
$attributes['email_verified_at'] = now();
|
|
73
|
+
}
|
|
74
|
+
$user = (new $model)->forceFill($attributes);
|
|
75
|
+
$user->save();
|
|
76
|
+
if ($role !== null) {
|
|
77
|
+
Access::grant($user, $role);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
$as = $role === null ? '' : " as {$role}";
|
|
81
|
+
$this->components->info("Created {$attributes['email']}{$as}. They can sign in to the dashboard now.");
|
|
82
|
+
if ($role !== null && $role !== Role::ADMIN && ! $this->option('role')) {
|
|
83
|
+
$this->line(" To give them another role: the dashboard's Users screen, or <fg=cyan>--role=ADMIN</> here.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return self::SUCCESS;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The role for the new account: null when this app has no roles yet (its migrations
|
|
91
|
+
* haven't run), false when the one asked for doesn't exist.
|
|
92
|
+
*/
|
|
93
|
+
private function role(bool $asked): string|false|null
|
|
94
|
+
{
|
|
95
|
+
if (! Access::ready()) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
$names = Role::query()->orderByDesc('is_system')->orderBy('name')->pluck('name')->all();
|
|
99
|
+
$wanted = $this->option('role');
|
|
100
|
+
if ($wanted !== null) {
|
|
101
|
+
foreach ($names as $name) {
|
|
102
|
+
if (strcasecmp($name, (string) $wanted) === 0) {
|
|
103
|
+
return $name;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
$this->components->error("There is no role called \"{$wanted}\". The roles are: ".implode(', ', $names).'.');
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
// Someone has to be able to manage the rest: the first account is the administrator.
|
|
111
|
+
if (Access::otherAdmins() === 0) {
|
|
112
|
+
return Role::ADMIN;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return $asked && $this->input->isInteractive() ? (string) $this->choice('Role', $names, Role::USER) : Role::USER;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private function sample(): int
|
|
119
|
+
{
|
|
120
|
+
if (! Access::ready()) {
|
|
121
|
+
$this->components->error('Roles need a migration that has not been run: php nevela migrate');
|
|
122
|
+
|
|
123
|
+
return self::FAILURE;
|
|
124
|
+
}
|
|
125
|
+
if (! SampleUsers::allowed()) {
|
|
126
|
+
$this->components->error('Sample users are for development: ten accounts sharing one password. This app is in production, so none were made.');
|
|
127
|
+
|
|
128
|
+
return self::FAILURE;
|
|
129
|
+
}
|
|
130
|
+
$password = (string) ($this->option('password') ?? 'password');
|
|
131
|
+
if (mb_strlen($password) < 8) {
|
|
132
|
+
$this->components->error('Give the sample users a password of at least 8 characters, or leave --password out for "password".');
|
|
133
|
+
|
|
134
|
+
return self::FAILURE;
|
|
135
|
+
}
|
|
136
|
+
$created = SampleUsers::create($password);
|
|
137
|
+
if ($created === []) {
|
|
138
|
+
$this->components->info('The sample users are already here.');
|
|
139
|
+
|
|
140
|
+
return self::SUCCESS;
|
|
141
|
+
}
|
|
142
|
+
foreach ($created as $person) {
|
|
143
|
+
$this->components->twoColumnDetail("{$person['name']} <fg=gray>{$person['email']}</>", $person['role'].($person['active'] ? '' : ' <fg=gray>(switched off)</>'));
|
|
144
|
+
}
|
|
145
|
+
$this->newLine();
|
|
146
|
+
$this->components->info('Created '.count($created)." sample users. They all sign in with the password \"{$password}\".");
|
|
147
|
+
$this->line(' They are in this database only. Delete them before real use: the dashboard\'s Users screen.');
|
|
148
|
+
|
|
149
|
+
return self::SUCCESS;
|
|
150
|
+
}
|
|
151
|
+
}
|