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.
Files changed (42) hide show
  1. package/README.md +18 -15
  2. package/index.mjs +38 -14
  3. package/package.json +1 -1
  4. package/template/laravel/README.md +6 -4
  5. package/template/laravel/config/nevela.php +17 -2
  6. package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
  7. package/template/laravel/src/Access/Access.php +168 -0
  8. package/template/laravel/src/Access/Permissions.php +271 -0
  9. package/template/laravel/src/Access/SampleUsers.php +81 -0
  10. package/template/laravel/src/Auth/Account.php +17 -1
  11. package/template/laravel/src/Console/SetupCommand.php +11 -2
  12. package/template/laravel/src/Console/UserCommand.php +151 -59
  13. package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
  14. package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
  15. package/template/laravel/src/Http/Access/RolesController.php +197 -0
  16. package/template/laravel/src/Http/Access/UsersController.php +382 -0
  17. package/template/laravel/src/Http/Auth/AccountController.php +5 -1
  18. package/template/laravel/src/Models/Role.php +73 -0
  19. package/template/laravel/src/Nevela.php +2 -1
  20. package/template/laravel/src/NevelaServiceProvider.php +29 -0
  21. package/template/web/app/api/[...path]/route.ts +5 -1
  22. package/template/web/app/dashboard/access/actions.ts +70 -0
  23. package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
  24. package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
  25. package/template/web/app/dashboard/access/roles/page.tsx +77 -0
  26. package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
  27. package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
  28. package/template/web/app/dashboard/access/users/page.tsx +158 -0
  29. package/template/web/app/dashboard/actions.ts +10 -9
  30. package/template/web/app/dashboard/import-actions.ts +6 -5
  31. package/template/web/app/dashboard/layout.tsx +5 -2
  32. package/template/web/app/dashboard/page.tsx +27 -4
  33. package/template/web/app/page.tsx +3 -9
  34. package/template/web/components/access/role-form.tsx +389 -0
  35. package/template/web/components/access/user-form.tsx +167 -0
  36. package/template/web/components/access/user-row-actions.tsx +117 -0
  37. package/template/web/components/access/users-toolbar.tsx +82 -0
  38. package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
  39. package/template/web/lib/access.ts +107 -0
  40. package/template/web/lib/dashboard.ts +51 -18
  41. package/template/web/lib/session.ts +11 -0
  42. 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
- 'role' => $user->role ?? null,
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 and a starter account';
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\Validator;
8
-
9
- final class UserCommand extends Command
10
- {
11
- protected $signature = 'nevela:user
12
- {--name= : The person\'s name}
13
- {--email= : The address they sign in with}
14
- {--password= : Their password. Leave out to be asked for it without it showing}';
15
-
16
- protected $description = 'Create a user who can sign in to the dashboard';
17
-
18
- public function handle(): int
19
- {
20
- $model = config('auth.providers.users.model');
21
- if (! is_string($model) || ! class_exists($model)) {
22
- $this->components->error('No user model is configured in config/auth.php (auth.providers.users.model).');
23
-
24
- return self::FAILURE;
25
- }
26
-
27
- $input = [
28
- 'name' => $this->option('name') ?? $this->ask('Name'),
29
- 'email' => $this->option('email') ?? $this->ask('Email'),
30
- 'password' => $this->option('password') ?? $this->secret('Password (at least 8 characters)'),
31
- ];
32
-
33
- $table = (new $model)->getTable();
34
- $validator = Validator::make($input, [
35
- 'name' => ['required', 'string', 'max:255'],
36
- 'email' => ['required', 'email', 'max:255', "unique:{$table},email"],
37
- 'password' => ['required', 'string', 'min:8'],
38
- ]);
39
- if ($validator->fails()) {
40
- foreach ($validator->errors()->all() as $message) {
41
- $this->components->error($message);
42
- }
43
-
44
- return self::FAILURE;
45
- }
46
-
47
- // forceFill: this is the operator creating an account, not a form to guard.
48
- $user = (new $model)->forceFill([
49
- 'name' => $input['name'],
50
- 'email' => $input['email'],
51
- 'password' => Hash::make($input['password']),
52
- ]);
53
- $user->save();
54
-
55
- $this->components->info("Created {$input['email']}. They can sign in to the dashboard now.");
56
-
57
- return self::SUCCESS;
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
+ }