create-nevela 0.6.0 → 0.7.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 (32) hide show
  1. package/package.json +1 -1
  2. package/template/laravel/config/nevela.php +7 -0
  3. package/template/laravel/database/migrations/2026_10_09_000000_create_nevela_closed_accounts.php +52 -0
  4. package/template/laravel/src/Access/Access.php +3 -3
  5. package/template/laravel/src/Access/ClosedAccounts.php +228 -0
  6. package/template/laravel/src/Access/SampleUsers.php +1 -3
  7. package/template/laravel/src/Auth/Account.php +7 -2
  8. package/template/laravel/src/Console/StatusCommand.php +10 -3
  9. package/template/laravel/src/Console/UserCommand.php +5 -1
  10. package/template/laravel/src/Generator/ResourceGenerator.php +2 -0
  11. package/template/laravel/src/Http/Access/Ceiling.php +56 -0
  12. package/template/laravel/src/Http/Access/DeletedAccountsController.php +155 -0
  13. package/template/laravel/src/Http/Access/RolesController.php +26 -2
  14. package/template/laravel/src/Http/Access/UsersController.php +39 -57
  15. package/template/laravel/src/Http/Auth/AccountController.php +45 -3
  16. package/template/laravel/src/Http/Auth/SignInController.php +5 -3
  17. package/template/laravel/src/Nevela.php +1 -1
  18. package/template/laravel/src/NevelaServiceProvider.php +9 -0
  19. package/template/web/app/auth-actions.ts +17 -0
  20. package/template/web/app/dashboard/access/actions.ts +15 -0
  21. package/template/web/app/dashboard/access/deleted/page.tsx +201 -0
  22. package/template/web/app/dashboard/access/users/page.tsx +1 -1
  23. package/template/web/app/dashboard/account/page.tsx +15 -10
  24. package/template/web/app/dashboard/layout.tsx +2 -1
  25. package/template/web/app/sign-in/page.tsx +8 -2
  26. package/template/web/components/access/deleted-account-actions.tsx +106 -0
  27. package/template/web/components/access/user-row-actions.tsx +16 -4
  28. package/template/web/components/account/sections.tsx +67 -0
  29. package/template/web/components/dashboard/dashboard-sidebar.tsx +4 -3
  30. package/template/web/lib/access.ts +42 -0
  31. package/template/web/lib/auth-config.ts +3 -1
  32. package/template/web/package.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,6 +86,13 @@ return [
86
86
  */
87
87
  'attempts_per_address' => 300,
88
88
 
89
+ /*
90
+ | Let people close their own account, from the dashboard's Account page. A closed
91
+ | account is kept under Deleted accounts, for an administrator to restore or remove
92
+ | for good. Off: only someone who may delete users can close one.
93
+ */
94
+ 'close_account' => true,
95
+
89
96
  /*
90
97
  | The role a self-registered account starts with. USER allows nothing beyond the
91
98
  | person's own account, which is what makes open sign-up safe. Null gives no role.
@@ -0,0 +1,52 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ // Deleted accounts.
8
+ //
9
+ // Closing an account, or deleting one from the Users screen, no longer removes it: it is
10
+ // marked closed, signed out everywhere, and kept so that it can be restored. And an email
11
+ // that had an account stays known after the account is removed for good, as a fingerprint
12
+ // the address can't be read back from, so the same person can't simply sign up again.
13
+ return new class extends Migration
14
+ {
15
+ public function up(): void
16
+ {
17
+ $model = config('auth.providers.users.model');
18
+ $users = is_string($model) && class_exists($model) ? (new $model)->getTable() : 'users';
19
+
20
+ if (Schema::hasTable($users)) {
21
+ Schema::table($users, function (Blueprint $table) use ($users) {
22
+ if (! Schema::hasColumn($users, 'closed_at')) {
23
+ // Set: the account is closed. It can't sign in, and isn't among the users.
24
+ $table->timestamp('closed_at')->nullable()->index();
25
+ }
26
+ if (! Schema::hasColumn($users, 'closed_by')) {
27
+ // The id of whoever closed it: the account's own id when its owner did.
28
+ $table->string('closed_by')->nullable();
29
+ }
30
+ });
31
+ }
32
+
33
+ Schema::create('nevela_blocked_emails', function (Blueprint $table) {
34
+ $table->uuid('id')->primary();
35
+ // HMAC-SHA256 of the address, keyed with the app key. Not the address.
36
+ $table->char('fingerprint', 64)->unique();
37
+ // Enough to recognise it by, for whoever decides to allow it again: "m•••@gmail.com".
38
+ $table->string('hint');
39
+ // The closed account it belongs to, while that is still kept. Null once the
40
+ // account has been removed for good: then this row is all that is left of it.
41
+ $table->string('user_id')->nullable()->index();
42
+ $table->timestamp('blocked_at');
43
+ });
44
+ }
45
+
46
+ public function down(): void
47
+ {
48
+ // The two columns stay: an app may have had its own before this ran, and a closed
49
+ // account that lost its mark would be an open one again.
50
+ Schema::dropIfExists('nevela_blocked_emails');
51
+ }
52
+ };
@@ -50,7 +50,7 @@ final class Access
50
50
  if (isset(self::$grants[$user])) {
51
51
  return self::$grants[$user];
52
52
  }
53
- if (! self::active($user)) {
53
+ if (! self::active($user) || ClosedAccounts::isClosed($user)) {
54
54
  return self::$grants[$user] = [];
55
55
  }
56
56
 
@@ -141,7 +141,7 @@ final class Access
141
141
  $key = $instance->getKeyName();
142
142
  $hasActive = Schema::hasColumn($table, 'active');
143
143
 
144
- $all =Role::query()->get()->filter(fn (Role $role) => Permissions::hasAll($role->grants))->pluck('id')->all();
144
+ $all = Role::query()->get()->filter(fn (Role $role) => Permissions::hasAll($role->grants))->pluck('id')->all();
145
145
  $ids = DB::table('nevela_role_user')->whereIn('role_id', $all)->distinct()->pluck('user_id')->all();
146
146
  if ($except !== null) {
147
147
  $ids = array_values(array_diff($ids, [(string) $except->getAuthIdentifier()]));
@@ -150,7 +150,7 @@ final class Access
150
150
  return 0;
151
151
  }
152
152
 
153
- return $model::query()->whereIn($key, $ids)->when($hasActive, fn ($query) => $query->where('active', true))->count();
153
+ return ClosedAccounts::open()->whereIn($key, $ids)->when($hasActive, fn ($query) => $query->where('active', true))->count();
154
154
  }
155
155
 
156
156
  /** Forget what has been worked out, after roles or who holds them change. */
@@ -0,0 +1,228 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Access;
4
+
5
+ use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Support\Facades\DB;
7
+ use Illuminate\Support\Facades\Schema;
8
+ use Illuminate\Support\Str;
9
+ use Nevela\Laravel\Auth\Account;
10
+
11
+ /**
12
+ * Accounts that were closed, and the emails that may not have a new one.
13
+ *
14
+ * A closed account is kept: its owner closed it, or someone deleted it from the Users
15
+ * screen. It can't sign in, is signed out everywhere and is no longer among the users,
16
+ * and it can be restored with everything it had. Removing it for good deletes the account
17
+ * and leaves a fingerprint of its email behind, so that signing up again with the same
18
+ * address is refused until someone allows it.
19
+ *
20
+ * The fingerprint is an HMAC of the address keyed with the app key: the address can't be
21
+ * read back from it, and it is of no use to anyone holding only the database.
22
+ */
23
+ final class ClosedAccounts
24
+ {
25
+ public const BLOCKED = 'nevela_blocked_emails';
26
+
27
+ private static bool $ready = false;
28
+
29
+ /**
30
+ * Whether the app has run the migration this needs. Until it has, deleting a user
31
+ * removes them as it always did, and nothing here is offered.
32
+ */
33
+ public static function ready(): bool
34
+ {
35
+ if (self::$ready) {
36
+ return true;
37
+ }
38
+ $model = Account::model();
39
+ if (! class_exists($model)) {
40
+ return false;
41
+ }
42
+
43
+ // Only "yes" is remembered: the column can arrive later in the same process.
44
+ return self::$ready = Schema::hasTable(self::BLOCKED) && Schema::hasColumn((new $model)->getTable(), 'closed_at');
45
+ }
46
+
47
+ /** For tests that add and drop the tables within one process. */
48
+ public static function reset(): void
49
+ {
50
+ self::$ready = false;
51
+ }
52
+
53
+ public static function isClosed(mixed $user): bool
54
+ {
55
+ $attributes = is_object($user) && method_exists($user, 'getAttributes') ? $user->getAttributes() : [];
56
+
57
+ return isset($attributes['closed_at']);
58
+ }
59
+
60
+ /** The app's users, without the closed ones. */
61
+ public static function open(): Builder
62
+ {
63
+ return Account::model()::query()->when(self::ready(), fn (Builder $query) => $query->whereNull('closed_at'));
64
+ }
65
+
66
+ /** Only the closed ones. Nobody, in an app that keeps none. */
67
+ public static function closed(): Builder
68
+ {
69
+ return Account::model()::query()->when(self::ready(), fn (Builder $query) => $query->whereNotNull('closed_at'), fn (Builder $query) => $query->whereRaw('1 = 0'));
70
+ }
71
+
72
+ /** @return list<string> The ids of the closed accounts, as the roles table keeps ids: text. */
73
+ public static function ids(): array
74
+ {
75
+ if (! self::ready()) {
76
+ return [];
77
+ }
78
+ $model = Account::model();
79
+
80
+ return self::closed()->pluck((new $model)->getKeyName())->map(fn ($id) => (string) $id)->all();
81
+ }
82
+
83
+ /**
84
+ * An address as the person behind it, for telling whether two are the same mailbox:
85
+ * lower case, without a "+tag", and for Gmail without the dots it ignores. So closing
86
+ * mark@gmail.com also covers m.ark+new@gmail.com.
87
+ */
88
+ public static function canonical(string $email): string
89
+ {
90
+ $email = mb_strtolower(trim($email));
91
+ $at = strrpos($email, '@');
92
+ if ($at === false) {
93
+ return $email;
94
+ }
95
+ $local = substr($email, 0, $at);
96
+ $domain = substr($email, $at + 1);
97
+ $plain = explode('+', $local, 2)[0];
98
+ if (in_array($domain, ['gmail.com', 'googlemail.com'], true)) {
99
+ $plain = str_replace('.', '', $plain);
100
+ $domain = 'gmail.com';
101
+ }
102
+
103
+ return ($plain !== '' ? $plain : $local).'@'.$domain;
104
+ }
105
+
106
+ public static function fingerprint(string $email): string
107
+ {
108
+ return hash_hmac('sha256', self::canonical($email), (string) config('app.key'));
109
+ }
110
+
111
+ /**
112
+ * The fingerprint an email has now, and the ones it had under the app's earlier keys
113
+ * (APP_PREVIOUS_KEYS). An email blocked before the key was rotated is still blocked.
114
+ *
115
+ * @return list<string>
116
+ */
117
+ public static function fingerprints(string $email): array
118
+ {
119
+ $canonical = self::canonical($email);
120
+ $keys = [(string) config('app.key'), ...array_filter((array) config('app.previous_keys', []), 'is_string')];
121
+
122
+ return array_values(array_unique(array_map(fn (string $key) => hash_hmac('sha256', $canonical, $key), $keys)));
123
+ }
124
+
125
+ /** "m•••@gmail.com": enough to recognise an address by, and not the address. */
126
+ public static function hint(string $email): string
127
+ {
128
+ $email = mb_strtolower(trim($email));
129
+ $at = strrpos($email, '@');
130
+
131
+ return $at === false || $at === 0 ? '•••' : mb_substr($email, 0, 1).'•••'.substr($email, $at);
132
+ }
133
+
134
+ /**
135
+ * What stands in the way of a new account with this email: "closed" when a closed
136
+ * account has it and could be restored, "blocked" when its account was removed for
137
+ * good, and null when nothing does.
138
+ *
139
+ * @param mixed $except An account whose own email this may be
140
+ */
141
+ public static function standing(string $email, mixed $except = null): ?string
142
+ {
143
+ if (! self::ready()) {
144
+ return null;
145
+ }
146
+ // An entry for a closed account first: that one can be restored.
147
+ $row = DB::table(self::BLOCKED)->whereIn('fingerprint', self::fingerprints($email))->orderByRaw('case when user_id is null then 1 else 0 end')->first();
148
+ if (! $row || ($except !== null && $row->user_id !== null && $row->user_id === (string) $except->getAuthIdentifier())) {
149
+ return null;
150
+ }
151
+
152
+ return $row->user_id !== null ? 'closed' : 'blocked';
153
+ }
154
+
155
+ /**
156
+ * Close an account. It is signed out everywhere, and kept.
157
+ *
158
+ * @param mixed $by Whoever is closing it: the account itself, or the person deleting it
159
+ */
160
+ public static function close(mixed $user, mixed $by = null): void
161
+ {
162
+ DB::transaction(function () use ($user, $by) {
163
+ $user->forceFill(['closed_at' => now(), 'closed_by' => $by === null ? null : (string) $by->getAuthIdentifier()])->save();
164
+ Account::revokeTokens($user);
165
+ self::block((string) $user->email, (string) $user->getAuthIdentifier());
166
+ });
167
+ Access::forget();
168
+ }
169
+
170
+ /** Open a closed account again, as it was: its roles, its password, its second step. */
171
+ public static function restore(mixed $user): void
172
+ {
173
+ DB::transaction(function () use ($user) {
174
+ DB::table(self::BLOCKED)->where('user_id', (string) $user->getAuthIdentifier())->delete();
175
+ $user->forceFill(['closed_at' => null, 'closed_by' => null])->save();
176
+ });
177
+ Access::forget();
178
+ }
179
+
180
+ /** Remove a closed account for good. Its email stays blocked until someone allows it. */
181
+ public static function purge(mixed $user): void
182
+ {
183
+ $email = (string) $user->email;
184
+ DB::transaction(function () use ($user, $email) {
185
+ self::remove($user);
186
+ self::block($email, null);
187
+ });
188
+ }
189
+
190
+ /** Delete an account and what hangs off it. Nothing is kept. */
191
+ public static function remove(mixed $user): void
192
+ {
193
+ $key = (string) $user->getAuthIdentifier();
194
+ DB::transaction(function () use ($user, $key) {
195
+ Account::revokeTokens($user);
196
+ DB::table('nevela_role_user')->where('user_id', $key)->delete();
197
+ foreach (['nevela_two_factor', 'nevela_passkeys'] as $table) {
198
+ if (Schema::hasTable($table)) {
199
+ DB::table($table)->where('user_id', $key)->delete();
200
+ }
201
+ }
202
+ $user->delete();
203
+ });
204
+ Access::forget();
205
+ }
206
+
207
+ /** Let an email whose account was removed for good be used again. False when there is no such entry. */
208
+ public static function allow(string $id): bool
209
+ {
210
+ return DB::table(self::BLOCKED)->where('id', $id)->whereNull('user_id')->delete() > 0;
211
+ }
212
+
213
+ /** Remember an email by its fingerprint: for a closed account, or with no account left. */
214
+ private static function block(string $email, ?string $userId): void
215
+ {
216
+ // One entry per mailbox, under today's key: an entry made under an earlier key is brought up to date.
217
+ $fingerprint = self::fingerprint($email);
218
+ $values = ['fingerprint' => $fingerprint, 'hint' => self::hint($email), 'user_id' => $userId, 'blocked_at' => now()];
219
+ $existing = DB::table(self::BLOCKED)->whereIn('fingerprint', self::fingerprints($email))->orderBy('blocked_at')->pluck('id')->all();
220
+ if ($existing === []) {
221
+ DB::table(self::BLOCKED)->insert(['id' => (string) Str::uuid()] + $values);
222
+
223
+ return;
224
+ }
225
+ DB::table(self::BLOCKED)->whereIn('id', array_slice($existing, 1))->delete();
226
+ DB::table(self::BLOCKED)->where('id', $existing[0])->update($values);
227
+ }
228
+ }
@@ -57,10 +57,8 @@ final class SampleUsers
57
57
  if (Account::findByEmail($email)) {
58
58
  continue;
59
59
  }
60
+ // Their addresses are made up, and nobody has proved one: they are not "verified".
60
61
  $attributes = ['name' => $name, 'email' => $email, 'password' => $hash];
61
- if (Schema::hasColumn($table, 'email_verified_at')) {
62
- $attributes['email_verified_at'] = now();
63
- }
64
62
  if (Schema::hasColumn($table, 'active')) {
65
63
  $attributes['active'] = $active;
66
64
  }
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\DB;
8
8
  use Illuminate\Support\Facades\Schema;
9
9
  use Illuminate\Validation\Rules\Password;
10
10
  use Nevela\Laravel\Access\Access;
11
+ use Nevela\Laravel\Access\ClosedAccounts;
11
12
  use Nevela\Laravel\Access\Permissions;
12
13
  use Nevela\Laravel\Media\Uploads;
13
14
  use Nevela\Laravel\Models\TwoFactor;
@@ -24,6 +25,7 @@ final class Account
24
25
  return (string) config('auth.providers.users.model');
25
26
  }
26
27
 
28
+ /** Whoever has this email, a closed account included: an address belongs to one account, open or not. */
27
29
  public static function findByEmail(string $email): mixed
28
30
  {
29
31
  return self::model()::query()->whereRaw('lower(email) = ?', [mb_strtolower(trim($email))])->first();
@@ -73,8 +75,11 @@ final class Account
73
75
  */
74
76
  public static function signIn(mixed $user, Request $request): array
75
77
  {
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
+ // Every way of signing in ends here, so this is the one place a closed or
79
+ // switched-off account is turned away, whichever way it came.
80
+ if (ClosedAccounts::isClosed($user)) {
81
+ throw new HttpResponseException(response()->json(['error' => 'This account was closed. Ask an administrator to restore it.', 'code' => 'ACCOUNT_CLOSED'], 403));
82
+ }
78
83
  if (! Access::active($user)) {
79
84
  throw new HttpResponseException(response()->json(['error' => 'This account has been switched off. Ask an administrator.', 'code' => 'ACCOUNT_DISABLED'], 403));
80
85
  }
@@ -5,6 +5,8 @@ namespace Nevela\Laravel\Console;
5
5
  use Illuminate\Console\Command;
6
6
  use Illuminate\Support\Facades\DB;
7
7
  use Illuminate\Support\Facades\Http;
8
+ use Illuminate\Support\Facades\Schema;
9
+ use Nevela\Laravel\Access\ClosedAccounts;
8
10
  use Nevela\Laravel\Nevela;
9
11
  use Nevela\Laravel\Support\DashboardState;
10
12
  use Nevela\Laravel\Support\Releases;
@@ -60,8 +62,10 @@ final class StatusCommand extends Command
60
62
  $model = config('auth.providers.users.model');
61
63
  if (is_string($model) && class_exists($model)) {
62
64
  try {
63
- $users = $model::query()->count();
64
- $row('Users who can sign in', $users > 0 ? (string) $users : $bad('none: php nevela user'));
65
+ // A closed account is kept, and can't sign in.
66
+ $users = ClosedAccounts::open()->count();
67
+ $closed = ClosedAccounts::closed()->count();
68
+ $row('Users who can sign in', ($users > 0 ? (string) $users : $bad('none: php nevela user')).($closed > 0 ? " <fg=gray>and {$closed} deleted, kept to restore</>" : ''));
65
69
  } catch (Throwable) {
66
70
  $row('Users who can sign in', $bad('the users table is missing: php nevela migrate'));
67
71
  }
@@ -82,7 +86,10 @@ final class StatusCommand extends Command
82
86
  }
83
87
  foreach ($resources as $resource) {
84
88
  try {
85
- $row("Resource: {$resource->name}", number_format(DB::table($resource->table)->count()).' records');
89
+ // What is in the trash is still in the table, and isn't one of the records.
90
+ $trashed = Schema::hasColumn($resource->table, 'deleted_at') ? DB::table($resource->table)->whereNotNull('deleted_at')->count() : 0;
91
+ $records = DB::table($resource->table)->count() - $trashed;
92
+ $row("Resource: {$resource->name}", number_format($records).' records'.($trashed > 0 ? ' <fg=gray>and '.number_format($trashed).' in the trash</>' : ''));
86
93
  } catch (Throwable) {
87
94
  $row("Resource: {$resource->name}", $bad("the {$resource->table} table is missing: php nevela migrate"));
88
95
  }
@@ -18,6 +18,7 @@ final class UserCommand extends Command
18
18
  {--email= : The address they sign in with}
19
19
  {--password= : Their password. Leave out to be asked for it without it showing}
20
20
  {--role= : Their role: ADMIN, EDITOR, USER or one you have made. The first account is an ADMIN}
21
+ {--verified : Mark the email as verified. Only for an address you know is theirs}
21
22
  {--sample : Create the ten sample users instead: two editors and eight users}';
22
23
 
23
24
  protected $description = 'Create a user who can sign in to the dashboard';
@@ -68,7 +69,10 @@ final class UserCommand extends Command
68
69
 
69
70
  // forceFill: this is the operator creating an account, not a form to guard.
70
71
  $attributes = ['name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password'])];
71
- if (Schema::hasColumn($table, 'email_verified_at')) {
72
+ // "Verified" means the owner has proved the address is theirs, by a link or a code
73
+ // sent to it. Making an account here proves nothing of the kind, so it starts
74
+ // unverified unless whoever runs this says they know the address is right.
75
+ if ($this->option('verified') && Schema::hasColumn($table, 'email_verified_at')) {
72
76
  $attributes['email_verified_at'] = now();
73
77
  }
74
78
  $user = (new $model)->forceFill($attributes);
@@ -359,6 +359,8 @@ final class ResourceGenerator
359
359
  requireEmailVerification: {$flag('require_email_verification', false)},
360
360
  /** Check new passwords against Have I Been Pwned's breach list. */
361
361
  checkBreachedPasswords: {$flag('check_breached_passwords')},
362
+ /** People can close their own account, from the Account page. */
363
+ closeAccount: {$flag('close_account')},
362
364
  /** Signing in with Google, GitHub and the like isn't part of Nevela yet. */
363
365
  social: [] as SocialProvider[],
364
366
  };
@@ -0,0 +1,56 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http\Access;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Nevela\Laravel\Access\Access;
7
+ use Nevela\Laravel\Access\Permissions;
8
+ use Nevela\Laravel\Models\Role;
9
+
10
+ /**
11
+ * The rule that managing accounts is never a way to take more than you were given: you
12
+ * act only on accounts that may do no more than you. Shared by the controllers that
13
+ * change, delete, restore and remove them.
14
+ */
15
+ trait Ceiling
16
+ {
17
+ /**
18
+ * A refusal when the account may do something the caller may not, and null otherwise.
19
+ *
20
+ * What the account may do is read from its roles, not from whether it is switched on:
21
+ * a switched-off administrator is still not for a lesser manager to give a new
22
+ * password and switch back on.
23
+ */
24
+ private function aboveCaller(mixed $caller, mixed $user, string $verb): ?JsonResponse
25
+ {
26
+ if (self::within(Access::grantsFor($caller), $user)) {
27
+ return null;
28
+ }
29
+
30
+ return $this->holdsEverything($user)
31
+ ? $this->refuse('ADMIN_ONLY', "Only an administrator can {$verb} an administrator's account.", 403)
32
+ : $this->refuse('ABOVE_YOUR_OWN', "You can't {$verb} this account: it may do things your own roles don't allow.", 403);
33
+ }
34
+
35
+ /**
36
+ * Whether everything an account's roles allow is covered by `$held`.
37
+ *
38
+ * @param list<string> $held
39
+ * @param iterable<Role>|null $roles The account's roles, when they are already at hand
40
+ */
41
+ private static function within(array $held, mixed $user, ?iterable $roles = null): bool
42
+ {
43
+ $theirs = [];
44
+ foreach ($roles ?? Access::rolesOf($user) as $role) {
45
+ array_push($theirs, ...$role->grants);
46
+ }
47
+
48
+ return Permissions::beyond($held, $theirs) === [];
49
+ }
50
+
51
+ /** Whether this account holds a role that comes to everything. */
52
+ private function holdsEverything(mixed $user): bool
53
+ {
54
+ return Access::rolesOf($user)->contains(fn (Role $role) => Permissions::hasAll($role->grants));
55
+ }
56
+ }
@@ -0,0 +1,155 @@
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\Facades\DB;
9
+ use Illuminate\Support\Facades\Gate;
10
+ use Nevela\Laravel\Access\ClosedAccounts;
11
+ use Nevela\Laravel\Auth\Account;
12
+ use Nevela\Laravel\Auth\AuthMail;
13
+ use Nevela\Laravel\Http\Auth\Answers;
14
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
15
+
16
+ /**
17
+ * Accounts that were closed: to restore, or to remove for good. And the emails whose
18
+ * accounts were removed for good, which can't sign up again until one is allowed.
19
+ *
20
+ * All of it is for whoever may delete users (users.delete), under the same rule as the
21
+ * Users screen: you act only on accounts that may do no more than you.
22
+ */
23
+ final class DeletedAccountsController
24
+ {
25
+ use Answers;
26
+ use Ceiling;
27
+
28
+ /** GET _nevela/deleted-accounts?q=&page=&perPage=: the latest closed first. */
29
+ public function index(Request $request): JsonResponse
30
+ {
31
+ Gate::authorize('users.delete');
32
+ if ($refused = $this->notReady()) {
33
+ return $refused;
34
+ }
35
+ $input = $request->validate([
36
+ 'q' => ['nullable', 'string', 'max:200'],
37
+ 'page' => ['nullable', 'integer', 'min:1'],
38
+ 'perPage' => ['nullable', 'integer', 'min:1', 'max:100'],
39
+ ]);
40
+ $model = Account::model();
41
+ $key = (new $model)->getKeyName();
42
+ $query = ClosedAccounts::closed();
43
+ if (($search = trim((string) ($input['q'] ?? ''))) !== '') {
44
+ $pattern = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_strtolower($search)).'%';
45
+ $query->where(fn ($where) => $where->whereRaw('lower(name) like ?', [$pattern])->orWhereRaw('lower(email) like ?', [$pattern]));
46
+ }
47
+
48
+ $perPage = (int) ($input['perPage'] ?? 25);
49
+ $page = (int) ($input['page'] ?? 1);
50
+ $total = (clone $query)->count();
51
+ $users = $query->orderByDesc('closed_at')->orderBy($key)->forPage($page, $perPage)->get();
52
+
53
+ // Who closed each: themselves, or someone whose name is worth showing.
54
+ $closers = $users->pluck('closed_by')->filter()->unique()->values()->all();
55
+ $names = $closers === [] ? collect() : $model::query()->whereIn($key, $closers)->pluck('name', $key);
56
+ $rows = UsersController::present($users, $request->user());
57
+ foreach ($users->values() as $index => $user) {
58
+ $by = $user->closed_by === null ? null : (string) $user->closed_by;
59
+ $own = $by !== null && $by === (string) $user->getAuthIdentifier();
60
+ $rows[$index] += [
61
+ 'closedAt' => $user->closed_at ? date(DATE_ATOM, strtotime((string) $user->closed_at)) : null,
62
+ // "self": its owner closed it. "admin": someone deleted it from the Users screen.
63
+ 'closedBy' => $by === null ? null : ($own ? 'self' : 'admin'),
64
+ 'closedByName' => $by === null || $own ? null : ($names[$by] ?? null),
65
+ ];
66
+ }
67
+
68
+ return response()->json([
69
+ 'data' => $rows,
70
+ 'meta' => ['page' => $page, 'perPage' => $perPage, 'total' => $total, 'totalPages' => max(1, (int) ceil($total / $perPage))],
71
+ 'blocked' => DB::table(ClosedAccounts::BLOCKED)->whereNull('user_id')->count(),
72
+ ]);
73
+ }
74
+
75
+ /** POST _nevela/deleted-accounts/{id}/restore: open it again, as it was. */
76
+ public function restore(Request $request, string $id): JsonResponse
77
+ {
78
+ Gate::authorize('users.delete');
79
+ if ($refused = $this->notReady()) {
80
+ return $refused;
81
+ }
82
+ $user = $this->find($id);
83
+ if ($refused = $this->aboveCaller($request->user(), $user, 'restore')) {
84
+ return $refused;
85
+ }
86
+ ClosedAccounts::restore($user);
87
+ AuthMail::send($user->email, 'Your '.AuthMail::app().' account is open again', ['Your account has been restored, with everything it had.', 'Sign in the way you did before: your password is the one you last used.']);
88
+
89
+ return response()->json(UsersController::present(collect([$user->refresh()]), $request->user())[0]);
90
+ }
91
+
92
+ /** DELETE _nevela/deleted-accounts/{id}: remove it for good. Its email stays blocked. */
93
+ public function destroy(Request $request, string $id): JsonResponse|Response
94
+ {
95
+ Gate::authorize('users.delete');
96
+ if ($refused = $this->notReady()) {
97
+ return $refused;
98
+ }
99
+ $user = $this->find($id);
100
+ if ($refused = $this->aboveCaller($request->user(), $user, 'remove')) {
101
+ return $refused;
102
+ }
103
+ ClosedAccounts::purge($user);
104
+
105
+ return response()->noContent();
106
+ }
107
+
108
+ /** GET _nevela/blocked-emails?page=&perPage=: emails whose accounts were removed for good. */
109
+ public function blocked(Request $request): JsonResponse
110
+ {
111
+ Gate::authorize('users.delete');
112
+ if ($refused = $this->notReady()) {
113
+ return $refused;
114
+ }
115
+ $input = $request->validate(['page' => ['nullable', 'integer', 'min:1'], 'perPage' => ['nullable', 'integer', 'min:1', 'max:100']]);
116
+ $perPage = (int) ($input['perPage'] ?? 25);
117
+ $page = (int) ($input['page'] ?? 1);
118
+ $query = DB::table(ClosedAccounts::BLOCKED)->whereNull('user_id');
119
+ $total = (clone $query)->count();
120
+
121
+ return response()->json([
122
+ 'data' => $query->orderByDesc('blocked_at')->orderBy('id')->forPage($page, $perPage)->get()->map(fn ($row) => [
123
+ 'id' => (string) $row->id,
124
+ 'hint' => $row->hint,
125
+ 'blockedAt' => date(DATE_ATOM, strtotime((string) $row->blocked_at)),
126
+ ])->all(),
127
+ 'meta' => ['page' => $page, 'perPage' => $perPage, 'total' => $total, 'totalPages' => max(1, (int) ceil($total / $perPage))],
128
+ ]);
129
+ }
130
+
131
+ /** DELETE _nevela/blocked-emails/{id}: let that email have an account again. */
132
+ public function allow(string $id): JsonResponse|Response
133
+ {
134
+ Gate::authorize('users.delete');
135
+ if ($refused = $this->notReady()) {
136
+ return $refused;
137
+ }
138
+ if (! ClosedAccounts::allow($id)) {
139
+ throw new NotFoundHttpException('No such blocked email.');
140
+ }
141
+
142
+ return response()->noContent();
143
+ }
144
+
145
+ private function find(string $id): mixed
146
+ {
147
+ return ClosedAccounts::closed()->find($id) ?? throw new NotFoundHttpException('No such deleted account.');
148
+ }
149
+
150
+ /** An upgraded app keeps no deleted accounts until it has run its migrations. */
151
+ private function notReady(): ?JsonResponse
152
+ {
153
+ return ClosedAccounts::ready() ? null : $this->refuse('MIGRATION_NEEDED', 'Deleted accounts need a migration that has not been run: php nevela migrate', 409);
154
+ }
155
+ }