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.
- package/package.json +1 -1
- package/template/laravel/config/nevela.php +7 -0
- package/template/laravel/database/migrations/2026_10_09_000000_create_nevela_closed_accounts.php +52 -0
- package/template/laravel/src/Access/Access.php +3 -3
- package/template/laravel/src/Access/ClosedAccounts.php +228 -0
- package/template/laravel/src/Access/SampleUsers.php +1 -3
- package/template/laravel/src/Auth/Account.php +7 -2
- package/template/laravel/src/Console/StatusCommand.php +10 -3
- package/template/laravel/src/Console/UserCommand.php +5 -1
- package/template/laravel/src/Generator/ResourceGenerator.php +2 -0
- package/template/laravel/src/Http/Access/Ceiling.php +56 -0
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +155 -0
- package/template/laravel/src/Http/Access/RolesController.php +26 -2
- package/template/laravel/src/Http/Access/UsersController.php +39 -57
- package/template/laravel/src/Http/Auth/AccountController.php +45 -3
- package/template/laravel/src/Http/Auth/SignInController.php +5 -3
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +9 -0
- package/template/web/app/auth-actions.ts +17 -0
- package/template/web/app/dashboard/access/actions.ts +15 -0
- package/template/web/app/dashboard/access/deleted/page.tsx +201 -0
- package/template/web/app/dashboard/access/users/page.tsx +1 -1
- package/template/web/app/dashboard/account/page.tsx +15 -10
- package/template/web/app/dashboard/layout.tsx +2 -1
- package/template/web/app/sign-in/page.tsx +8 -2
- package/template/web/components/access/deleted-account-actions.tsx +106 -0
- package/template/web/components/access/user-row-actions.tsx +16 -4
- package/template/web/components/account/sections.tsx +67 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +4 -3
- package/template/web/lib/access.ts +42 -0
- package/template/web/lib/auth-config.ts +3 -1
- package/template/web/package.json +1 -1
package/package.json
CHANGED
|
@@ -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.
|
package/template/laravel/database/migrations/2026_10_09_000000_create_nevela_closed_accounts.php
ADDED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
64
|
-
$
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|