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
|
@@ -9,6 +9,7 @@ use Illuminate\Support\Facades\DB;
|
|
|
9
9
|
use Illuminate\Support\Facades\Gate;
|
|
10
10
|
use Illuminate\Validation\Rule;
|
|
11
11
|
use Nevela\Laravel\Access\Access;
|
|
12
|
+
use Nevela\Laravel\Access\ClosedAccounts;
|
|
12
13
|
use Nevela\Laravel\Access\Permissions;
|
|
13
14
|
use Nevela\Laravel\Http\Auth\Answers;
|
|
14
15
|
use Nevela\Laravel\Models\Role;
|
|
@@ -41,7 +42,7 @@ final class RolesController
|
|
|
41
42
|
if (! $user->can('roles.view') && ! $user->can('users.view')) {
|
|
42
43
|
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
43
44
|
}
|
|
44
|
-
$counts =
|
|
45
|
+
$counts = self::holders();
|
|
45
46
|
$held = Access::grantsFor($user);
|
|
46
47
|
$roles = Role::query()->orderByDesc('is_system')->orderBy('name')->get();
|
|
47
48
|
|
|
@@ -128,7 +129,30 @@ final class RolesController
|
|
|
128
129
|
|
|
129
130
|
private function users(Role $role): int
|
|
130
131
|
{
|
|
131
|
-
return
|
|
132
|
+
return self::holders($role->id)[$role->id] ?? 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* How many people hold each role, or the one role asked about. Closed accounts keep
|
|
137
|
+
* their roles, for when they are restored, and aren't counted.
|
|
138
|
+
*
|
|
139
|
+
* @return array<string, int>
|
|
140
|
+
*/
|
|
141
|
+
private static function holders(?string $role = null): array
|
|
142
|
+
{
|
|
143
|
+
$links = fn () => DB::table('nevela_role_user')->when($role !== null, fn ($query) => $query->where('role_id', $role));
|
|
144
|
+
$perRole = fn ($query) => $query->groupBy('role_id')->selectRaw('role_id, count(*) as users')->pluck('users', 'role_id');
|
|
145
|
+
|
|
146
|
+
$counts = $perRole($links())->map(fn ($users) => (int) $users)->all();
|
|
147
|
+
// Taken off in batches. A list of ids is bound one value at a time, a database takes
|
|
148
|
+
// only so many, and the list of closed accounts only grows.
|
|
149
|
+
foreach (array_chunk(ClosedAccounts::ids(), 500) as $closed) {
|
|
150
|
+
foreach ($perRole($links()->whereIn('user_id', $closed)) as $id => $users) {
|
|
151
|
+
$counts[$id] = ($counts[$id] ?? 0) - (int) $users;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return $counts;
|
|
132
156
|
}
|
|
133
157
|
|
|
134
158
|
/** @return array{name?: string, description?: string|null, grants?: list<string>} */
|
|
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Hash;
|
|
|
12
12
|
use Illuminate\Support\Facades\Schema;
|
|
13
13
|
use Illuminate\Validation\Rule;
|
|
14
14
|
use Nevela\Laravel\Access\Access;
|
|
15
|
+
use Nevela\Laravel\Access\ClosedAccounts;
|
|
15
16
|
use Nevela\Laravel\Access\Permissions;
|
|
16
17
|
use Nevela\Laravel\Auth\Account;
|
|
17
18
|
use Nevela\Laravel\Auth\AuthMail;
|
|
@@ -31,10 +32,14 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
31
32
|
* password, or their email and then asking for a reset, would be a way to sign in as
|
|
32
33
|
* them and have what they have;
|
|
33
34
|
* - the last administrator can't be removed, switched off or demoted, by anyone.
|
|
35
|
+
*
|
|
36
|
+
* A closed account is not among the users here: it is under Deleted accounts until it is
|
|
37
|
+
* restored. Deleting a user is what puts it there.
|
|
34
38
|
*/
|
|
35
39
|
final class UsersController
|
|
36
40
|
{
|
|
37
41
|
use Answers;
|
|
42
|
+
use Ceiling;
|
|
38
43
|
|
|
39
44
|
/** GET _nevela/users?q=&role=&status=&page=&perPage= */
|
|
40
45
|
public function index(Request $request): JsonResponse
|
|
@@ -49,7 +54,7 @@ final class UsersController
|
|
|
49
54
|
]);
|
|
50
55
|
$model = Account::model();
|
|
51
56
|
$instance = new $model;
|
|
52
|
-
$query =
|
|
57
|
+
$query = ClosedAccounts::open();
|
|
53
58
|
|
|
54
59
|
if (($search = trim((string) ($input['q'] ?? ''))) !== '') {
|
|
55
60
|
$pattern = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_strtolower($search)).'%';
|
|
@@ -70,7 +75,8 @@ final class UsersController
|
|
|
70
75
|
|
|
71
76
|
return response()->json([
|
|
72
77
|
'data' => self::present($users, $request->user()),
|
|
73
|
-
|
|
78
|
+
// keepsDeleted: whether deleting a user here closes the account, to be restored, or removes it.
|
|
79
|
+
'meta' => ['page' => $page, 'perPage' => $perPage, 'total' => $total, 'totalPages' => max(1, (int) ceil($total / $perPage)), 'keepsDeleted' => ClosedAccounts::ready()],
|
|
74
80
|
]);
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -96,8 +102,8 @@ final class UsersController
|
|
|
96
102
|
'roles.*' => ['string', 'max:64'],
|
|
97
103
|
'active' => ['nullable', 'boolean'],
|
|
98
104
|
]);
|
|
99
|
-
if (
|
|
100
|
-
return
|
|
105
|
+
if ($taken = $this->emailTaken($input['email'])) {
|
|
106
|
+
return $taken;
|
|
101
107
|
}
|
|
102
108
|
$roles = $this->roles($input['roles']);
|
|
103
109
|
if ($roles instanceof JsonResponse) {
|
|
@@ -111,10 +117,8 @@ final class UsersController
|
|
|
111
117
|
if (Schema::hasColumn($user->getTable(), 'active')) {
|
|
112
118
|
$attributes['active'] = (bool) ($input['active'] ?? true);
|
|
113
119
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
$attributes['email_verified_at'] = now();
|
|
117
|
-
}
|
|
120
|
+
// The address is as typed by whoever is adding them. It becomes "verified" when its
|
|
121
|
+
// owner proves it: from their Account page, or by signing in with an emailed link or code.
|
|
118
122
|
$user->forceFill($attributes)->save();
|
|
119
123
|
Access::assign($user, $roles->pluck('id')->all());
|
|
120
124
|
|
|
@@ -141,11 +145,8 @@ final class UsersController
|
|
|
141
145
|
if (! $self && ($refused = $this->aboveCaller($caller, $user, 'change'))) {
|
|
142
146
|
return $refused;
|
|
143
147
|
}
|
|
144
|
-
if (isset($input['email'])) {
|
|
145
|
-
$taken
|
|
146
|
-
if ($taken && (string) $taken->getAuthIdentifier() !== (string) $user->getAuthIdentifier()) {
|
|
147
|
-
return response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => 'Someone already has this email.']]], 422);
|
|
148
|
-
}
|
|
148
|
+
if (isset($input['email']) && ($taken = $this->emailTaken($input['email'], $user))) {
|
|
149
|
+
return $taken;
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
$roles = null;
|
|
@@ -203,7 +204,11 @@ final class UsersController
|
|
|
203
204
|
return response()->json(self::present(collect([$user->refresh()]), $caller)[0]);
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
/**
|
|
207
|
+
/**
|
|
208
|
+
* DELETE _nevela/users/{id}: close the account. It is signed out everywhere and kept
|
|
209
|
+
* under Deleted accounts, to be restored or removed for good. In an app that hasn't
|
|
210
|
+
* run the migration for that yet, it is removed, as it always was.
|
|
211
|
+
*/
|
|
207
212
|
public function destroy(Request $request, string $id): JsonResponse|Response
|
|
208
213
|
{
|
|
209
214
|
Gate::authorize('users.delete');
|
|
@@ -219,18 +224,11 @@ final class UsersController
|
|
|
219
224
|
return $this->refuse('LAST_ADMIN', 'This is the only administrator. Make someone else an administrator first.', 409);
|
|
220
225
|
}
|
|
221
226
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (Schema::hasTable($table)) {
|
|
228
|
-
DB::table($table)->where('user_id', $key)->delete();
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
$user->delete();
|
|
232
|
-
});
|
|
233
|
-
Access::forget();
|
|
227
|
+
if (ClosedAccounts::ready()) {
|
|
228
|
+
ClosedAccounts::close($user, $caller);
|
|
229
|
+
} else {
|
|
230
|
+
ClosedAccounts::remove($user);
|
|
231
|
+
}
|
|
234
232
|
|
|
235
233
|
return response()->noContent();
|
|
236
234
|
}
|
|
@@ -250,47 +248,31 @@ final class UsersController
|
|
|
250
248
|
|
|
251
249
|
private function find(string $id): mixed
|
|
252
250
|
{
|
|
253
|
-
return
|
|
251
|
+
return ClosedAccounts::open()->find($id) ?? throw new NotFoundHttpException('No such user.');
|
|
254
252
|
}
|
|
255
253
|
|
|
256
254
|
/**
|
|
257
|
-
* A
|
|
255
|
+
* A 422 when an email can't be given to an account: someone has it, a deleted account
|
|
256
|
+
* has it, or its account was removed for good and the address is blocked.
|
|
258
257
|
*
|
|
259
|
-
*
|
|
260
|
-
* a switched-off administrator is still not for a lesser manager to give a new
|
|
261
|
-
* password and switch back on.
|
|
258
|
+
* @param mixed $except The account being changed, whose own email it may be
|
|
262
259
|
*/
|
|
263
|
-
private function
|
|
260
|
+
private function emailTaken(string $email, mixed $except = null): ?JsonResponse
|
|
264
261
|
{
|
|
265
|
-
|
|
262
|
+
$issue = fn (string $message) => response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => $message]]], 422);
|
|
263
|
+
$holder = Account::findByEmail($email);
|
|
264
|
+
if ($holder && $except !== null && (string) $holder->getAuthIdentifier() === (string) $except->getAuthIdentifier()) {
|
|
266
265
|
return null;
|
|
267
266
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
? $this->refuse('ADMIN_ONLY', "Only an administrator can {$verb} an administrator's account.", 403)
|
|
271
|
-
: $this->refuse('ABOVE_YOUR_OWN', "You can't {$verb} this account: it may do things your own roles don't allow.", 403);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Whether everything an account's roles allow is covered by `$held`.
|
|
276
|
-
*
|
|
277
|
-
* @param list<string> $held
|
|
278
|
-
* @param iterable<Role>|null $roles The account's roles, when they are already at hand
|
|
279
|
-
*/
|
|
280
|
-
private static function within(array $held, mixed $user, ?iterable $roles = null): bool
|
|
281
|
-
{
|
|
282
|
-
$theirs = [];
|
|
283
|
-
foreach ($roles ?? Access::rolesOf($user) as $role) {
|
|
284
|
-
array_push($theirs, ...$role->grants);
|
|
267
|
+
if ($holder && ! ClosedAccounts::isClosed($holder)) {
|
|
268
|
+
return $issue('Someone already has this email.');
|
|
285
269
|
}
|
|
286
270
|
|
|
287
|
-
return
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
{
|
|
293
|
-
return Access::rolesOf($user)->contains(fn (Role $role) => Permissions::hasAll($role->grants));
|
|
271
|
+
return match ($holder ? 'closed' : ClosedAccounts::standing($email, $except)) {
|
|
272
|
+
'closed' => $issue('A deleted account has this email. Restore it from Deleted accounts.'),
|
|
273
|
+
'blocked' => $issue('This email is blocked: its account was removed for good. Allow it again under Deleted accounts first.'),
|
|
274
|
+
default => null,
|
|
275
|
+
};
|
|
294
276
|
}
|
|
295
277
|
|
|
296
278
|
/**
|
|
@@ -9,11 +9,14 @@ use Illuminate\Support\Facades\DB;
|
|
|
9
9
|
use Illuminate\Support\Facades\Hash;
|
|
10
10
|
use Illuminate\Support\Facades\Schema;
|
|
11
11
|
use Nevela\Laravel\Access\Access;
|
|
12
|
+
use Nevela\Laravel\Access\ClosedAccounts;
|
|
13
|
+
use Nevela\Laravel\Access\Permissions;
|
|
12
14
|
use Nevela\Laravel\Auth\Account;
|
|
13
15
|
use Nevela\Laravel\Auth\AuthMail;
|
|
14
16
|
use Nevela\Laravel\Auth\Challenges;
|
|
15
17
|
use Nevela\Laravel\Auth\Web;
|
|
16
18
|
use Nevela\Laravel\Http\UploadController;
|
|
19
|
+
use Nevela\Laravel\Models\Role;
|
|
17
20
|
use Nevela\Laravel\Models\Upload;
|
|
18
21
|
use Nevela\Laravel\Rules\UploadKey;
|
|
19
22
|
use Nevela\Laravel\Support\Descriptor;
|
|
@@ -21,7 +24,8 @@ use Nevela\Laravel\Support\Field;
|
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* An account and what its owner can do with it: create it, prove the email address,
|
|
24
|
-
* change the name, the picture and the password,
|
|
27
|
+
* change the name, the picture and the password, see and sign out its devices, and
|
|
28
|
+
* close it.
|
|
25
29
|
*/
|
|
26
30
|
final class AccountController
|
|
27
31
|
{
|
|
@@ -41,7 +45,17 @@ final class AccountController
|
|
|
41
45
|
'email' => ['required', 'email', 'max:255'],
|
|
42
46
|
'password' => Account::passwordRules(),
|
|
43
47
|
]);
|
|
44
|
-
|
|
48
|
+
// An email that had an account here doesn't get a second one by signing up again:
|
|
49
|
+
// not the address itself, and not another spelling of the same mailbox.
|
|
50
|
+
$holder = Account::findByEmail($input['email']);
|
|
51
|
+
$standing = $holder && ClosedAccounts::isClosed($holder) ? 'closed' : ClosedAccounts::standing($input['email']);
|
|
52
|
+
if ($standing === 'closed') {
|
|
53
|
+
return $this->refuse('ACCOUNT_CLOSED', 'An account with this email was closed. Ask an administrator to restore it.', 422);
|
|
54
|
+
}
|
|
55
|
+
if ($standing === 'blocked') {
|
|
56
|
+
return $this->refuse('EMAIL_BLOCKED', "This email can't be used for a new account. Ask an administrator.", 422);
|
|
57
|
+
}
|
|
58
|
+
if ($holder) {
|
|
45
59
|
return $this->refuse('USER_ALREADY_EXISTS', 'An account with this email already exists. Sign in instead.', 422);
|
|
46
60
|
}
|
|
47
61
|
|
|
@@ -185,7 +199,7 @@ final class AccountController
|
|
|
185
199
|
public function forgotPassword(Request $request): JsonResponse
|
|
186
200
|
{
|
|
187
201
|
$input = $request->validate(['email' => ['required', 'email']]);
|
|
188
|
-
if ($user = Account::findByEmail($input['email'])) {
|
|
202
|
+
if (($user = Account::findByEmail($input['email'])) && ! ClosedAccounts::isClosed($user)) {
|
|
189
203
|
$token = Challenges::start('reset-password', ['user' => (string) $user->getAuthIdentifier()], self::RESET_SECONDS);
|
|
190
204
|
$url = Web::url('/reset-password', ['token' => $token], $request);
|
|
191
205
|
AuthMail::send($user->email, 'Reset your '.AuthMail::app().' password', ['Use this link to choose a new password. It works once and expires in an hour.'], ['Choose a new password', $url], $url);
|
|
@@ -211,6 +225,34 @@ final class AccountController
|
|
|
211
225
|
return response()->json(['reset' => true]);
|
|
212
226
|
}
|
|
213
227
|
|
|
228
|
+
/**
|
|
229
|
+
* POST auth/close: close your own account. It takes the password again.
|
|
230
|
+
*
|
|
231
|
+
* The account is signed out everywhere and kept: an administrator can restore it, with
|
|
232
|
+
* everything it had, or remove it for good. Its email can't sign up again meanwhile.
|
|
233
|
+
*/
|
|
234
|
+
public function close(Request $request): JsonResponse
|
|
235
|
+
{
|
|
236
|
+
$this->enabled('close_account');
|
|
237
|
+
if (! ClosedAccounts::ready()) {
|
|
238
|
+
return $this->refuse('MIGRATION_NEEDED', 'Closing an account needs a migration that has not been run: php nevela migrate', 409);
|
|
239
|
+
}
|
|
240
|
+
$user = $request->user();
|
|
241
|
+
if (! $this->passwordConfirmed($request)) {
|
|
242
|
+
return $this->refuse('INVALID_PASSWORD', "That isn't your password.", 422);
|
|
243
|
+
}
|
|
244
|
+
// Nobody would be left who could restore it, or run the app.
|
|
245
|
+
$administrator = Access::ready() && Access::rolesOf($user)->contains(fn (Role $role) => Permissions::hasAll($role->grants));
|
|
246
|
+
if ($administrator && Access::active($user) && Access::otherAdmins($user) === 0) {
|
|
247
|
+
return $this->refuse('LAST_ADMIN', 'You are the only administrator. Make someone else an administrator before closing your account.', 409);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ClosedAccounts::close($user, $user);
|
|
251
|
+
AuthMail::send($user->email, 'Your '.AuthMail::app().' account was closed', ['You closed your account, and have been signed out everywhere.', 'To have it back, ask an administrator: it can be restored with everything it had.']);
|
|
252
|
+
|
|
253
|
+
return response()->json(['closed' => true]);
|
|
254
|
+
}
|
|
255
|
+
|
|
214
256
|
/** GET auth/sessions: the devices signed in to this account. */
|
|
215
257
|
public function sessions(Request $request): JsonResponse
|
|
216
258
|
{
|
|
@@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse;
|
|
|
6
6
|
use Illuminate\Http\Request;
|
|
7
7
|
use Illuminate\Support\Facades\DB;
|
|
8
8
|
use Illuminate\Support\Facades\Hash;
|
|
9
|
+
use Nevela\Laravel\Access\ClosedAccounts;
|
|
9
10
|
use Nevela\Laravel\Auth\Account;
|
|
10
11
|
use Nevela\Laravel\Auth\AuthMail;
|
|
11
12
|
use Nevela\Laravel\Auth\Challenges;
|
|
@@ -49,6 +50,7 @@ final class SignInController
|
|
|
49
50
|
'email' => (bool) config('nevela.auth.two_factor.email', true),
|
|
50
51
|
],
|
|
51
52
|
'requireEmailVerification' => (bool) config('nevela.auth.require_email_verification', false),
|
|
53
|
+
'closeAccount' => (bool) config('nevela.auth.close_account', true),
|
|
52
54
|
];
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -205,8 +207,8 @@ final class SignInController
|
|
|
205
207
|
$this->enabled('magic_link');
|
|
206
208
|
$input = $request->validate(['email' => ['required', 'email'], 'next' => ['nullable', 'string', 'max:2000']]);
|
|
207
209
|
|
|
208
|
-
// The answer is the same whether or not the address has an account.
|
|
209
|
-
if ($user = Account::findByEmail($input['email'])) {
|
|
210
|
+
// The answer is the same whether or not the address has an account. A closed one gets no link.
|
|
211
|
+
if (($user = Account::findByEmail($input['email'])) && ! ClosedAccounts::isClosed($user)) {
|
|
210
212
|
$token = Challenges::start('magic-link', ['user' => (string) $user->getAuthIdentifier()], self::EMAILED);
|
|
211
213
|
$url = Web::url('/api/auth/magic-link', ['token' => $token, 'next' => Web::path($input['next'] ?? null)], $request);
|
|
212
214
|
AuthMail::send($user->email, 'Sign in to '.AuthMail::app(), ['Use this link to sign in. It works once and expires in 5 minutes.'], ['Sign in', $url], $url);
|
|
@@ -238,7 +240,7 @@ final class SignInController
|
|
|
238
240
|
$this->enabled('email_code');
|
|
239
241
|
$input = $request->validate(['email' => ['required', 'email']]);
|
|
240
242
|
|
|
241
|
-
if (($user = Account::findByEmail($input['email'])) && Challenges::mayIssue('email-code', mb_strtolower($user->email))) {
|
|
243
|
+
if (($user = Account::findByEmail($input['email'])) && ! ClosedAccounts::isClosed($user) && Challenges::mayIssue('email-code', mb_strtolower($user->email))) {
|
|
242
244
|
[$code, $hash] = Challenges::code();
|
|
243
245
|
Challenges::put('email-code', mb_strtolower($user->email), ['user' => (string) $user->getAuthIdentifier(), 'code' => $hash], self::EMAILED);
|
|
244
246
|
AuthMail::send($user->email, 'Your '.AuthMail::app().' sign-in code', ["Your sign-in code is {$code}.", 'It expires in 5 minutes.'], null, $code);
|
|
@@ -21,7 +21,7 @@ use Nevela\Laravel\Support\Naming;
|
|
|
21
21
|
final class Nevela
|
|
22
22
|
{
|
|
23
23
|
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
24
|
-
public const VERSION = '0.
|
|
24
|
+
public const VERSION = '0.7.1';
|
|
25
25
|
|
|
26
26
|
/** @var array<string, Descriptor> */
|
|
27
27
|
private static array $descriptors = [];
|
|
@@ -23,6 +23,7 @@ use Nevela\Laravel\Console\TrashCommand;
|
|
|
23
23
|
use Nevela\Laravel\Console\UpdateCommand;
|
|
24
24
|
use Nevela\Laravel\Console\UserCommand;
|
|
25
25
|
use Nevela\Laravel\Console\VersionCommand;
|
|
26
|
+
use Nevela\Laravel\Http\Access\DeletedAccountsController;
|
|
26
27
|
use Nevela\Laravel\Http\Access\RolesAreSetUp;
|
|
27
28
|
use Nevela\Laravel\Http\Access\RolesController;
|
|
28
29
|
use Nevela\Laravel\Http\Access\UsersController;
|
|
@@ -142,6 +143,7 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
142
143
|
Route::put('avatar', [AccountController::class, 'avatar'])->name('avatar');
|
|
143
144
|
Route::delete('token', [TokenController::class, 'destroy'])->name('destroy');
|
|
144
145
|
Route::post('password', [AccountController::class, 'changePassword'])->middleware('throttle:nevela-auth')->name('password.change');
|
|
146
|
+
Route::post('close', [AccountController::class, 'close'])->middleware('throttle:nevela-auth')->name('close');
|
|
145
147
|
|
|
146
148
|
Route::get('sessions', [AccountController::class, 'sessions'])->name('sessions');
|
|
147
149
|
Route::delete('sessions', [AccountController::class, 'revokeOtherSessions'])->name('sessions.revoke-others');
|
|
@@ -199,6 +201,13 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
199
201
|
Route::delete('_nevela/users/{id}', [UsersController::class, 'destroy'])->name('users.destroy');
|
|
200
202
|
Route::delete('_nevela/users/{id}/sessions', [UsersController::class, 'revokeSessions'])->name('users.sessions.revoke');
|
|
201
203
|
|
|
204
|
+
// Accounts that were closed, and the emails that can't have a new one.
|
|
205
|
+
Route::get('_nevela/deleted-accounts', [DeletedAccountsController::class, 'index'])->name('deleted-accounts.index');
|
|
206
|
+
Route::post('_nevela/deleted-accounts/{id}/restore', [DeletedAccountsController::class, 'restore'])->name('deleted-accounts.restore');
|
|
207
|
+
Route::delete('_nevela/deleted-accounts/{id}', [DeletedAccountsController::class, 'destroy'])->name('deleted-accounts.destroy');
|
|
208
|
+
Route::get('_nevela/blocked-emails', [DeletedAccountsController::class, 'blocked'])->name('blocked-emails.index');
|
|
209
|
+
Route::delete('_nevela/blocked-emails/{id}', [DeletedAccountsController::class, 'allow'])->name('blocked-emails.allow');
|
|
210
|
+
|
|
202
211
|
Route::get('_nevela/permissions', [RolesController::class, 'catalog'])->name('permissions');
|
|
203
212
|
Route::get('_nevela/roles', [RolesController::class, 'index'])->name('roles.index');
|
|
204
213
|
Route::post('_nevela/roles', [RolesController::class, 'store'])->name('roles.store');
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
3
|
import { cookies } from "next/headers";
|
|
4
|
+
import { redirect } from "next/navigation";
|
|
4
5
|
import { laravel, TOKEN_COOKIE } from "@/lib/laravel";
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Close the signed-in person's own account (`POST /auth/close`). Laravel checks the
|
|
9
|
+
* password, signs every device out and keeps the account for an administrator to restore.
|
|
10
|
+
* What is left here is a cookie for a token that no longer exists, so it goes too.
|
|
11
|
+
*/
|
|
12
|
+
export async function closeAccountAction(password: string): Promise<{ ok: false; error: string }> {
|
|
13
|
+
const response = await laravel("auth/close", { method: "POST", body: { password } });
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
const body = (response.body ?? {}) as { error?: string };
|
|
16
|
+
return { ok: false, error: body.error ?? `Something went wrong (${response.status}).` };
|
|
17
|
+
}
|
|
18
|
+
(await cookies()).set(TOKEN_COOKIE, "", { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 0 });
|
|
19
|
+
// Closed, it doesn't come back here: the sign-in page says what happened.
|
|
20
|
+
redirect("/sign-in?closed=1");
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
/** Revoke the token in Laravel (`DELETE /auth/token`) and forget it here. */
|
|
7
24
|
export async function signOutAction(): Promise<void> {
|
|
8
25
|
try {
|
|
@@ -46,6 +46,21 @@ export async function deleteUserAction(id: string): Promise<AccessResult<null>>
|
|
|
46
46
|
return send(`_nevela/users/${encodeURIComponent(id)}`, "DELETE");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Open a closed account again, as it was. */
|
|
50
|
+
export async function restoreAccountAction(id: string): Promise<AccessResult<ManagedUser>> {
|
|
51
|
+
return send(`_nevela/deleted-accounts/${encodeURIComponent(id)}/restore`, "POST");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Remove a closed account for good. Its email stays blocked. */
|
|
55
|
+
export async function removeAccountAction(id: string): Promise<AccessResult<null>> {
|
|
56
|
+
return send(`_nevela/deleted-accounts/${encodeURIComponent(id)}`, "DELETE");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Let an email whose account was removed for good sign up again. */
|
|
60
|
+
export async function allowEmailAction(id: string): Promise<AccessResult<null>> {
|
|
61
|
+
return send(`_nevela/blocked-emails/${encodeURIComponent(id)}`, "DELETE");
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
/** Sign someone out of every device. */
|
|
50
65
|
export async function signOutUserAction(id: string): Promise<AccessResult<{ revoked: number }>> {
|
|
51
66
|
return send(`_nevela/users/${encodeURIComponent(id)}/sessions`, "DELETE");
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { SearchIcon } from "lucide-react";
|
|
3
|
+
import { AllowEmailButton, DeletedAccountActions } from "@/components/access/deleted-account-actions";
|
|
4
|
+
import { LocalTime } from "@/components/dashboard/local-time";
|
|
5
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
6
|
+
import { UserAvatar } from "@/components/dashboard/user-menu";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
12
|
+
import { listBlockedEmails, listDeletedAccounts } from "@/lib/access";
|
|
13
|
+
import { requirePermission } from "@/lib/dashboard";
|
|
14
|
+
|
|
15
|
+
export const metadata = { title: "Deleted accounts" };
|
|
16
|
+
|
|
17
|
+
type Params = { q?: string; page?: string };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Accounts that were closed: by their owners, or by someone deleting them from the Users
|
|
21
|
+
* screen. Each is kept as it was, so it can be restored, or removed for good. Below them,
|
|
22
|
+
* the emails whose accounts were removed for good, which can't sign up again.
|
|
23
|
+
*/
|
|
24
|
+
export default async function DeletedAccountsPage({ searchParams }: { searchParams: Promise<Params> }) {
|
|
25
|
+
await requirePermission("users.delete", "/dashboard/access/deleted");
|
|
26
|
+
const params = await searchParams;
|
|
27
|
+
const list = await listDeletedAccounts(params);
|
|
28
|
+
const ready = list !== null && list !== "migrate";
|
|
29
|
+
const accounts = ready ? list.data : [];
|
|
30
|
+
const meta = ready ? list.meta : { page: 1, perPage: 25, total: 0, totalPages: 1 };
|
|
31
|
+
const blocked = ready && list.blocked > 0 ? await listBlockedEmails() : [];
|
|
32
|
+
|
|
33
|
+
const pageHref = (page: number) => `/dashboard/access/deleted?${new URLSearchParams({ ...(params.q ? { q: params.q } : {}), page: String(page) })}`;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<PageHeader
|
|
38
|
+
title="Deleted accounts"
|
|
39
|
+
description="Accounts that were closed. They can't sign in, and their email can't sign up again. Restore one as it was, or remove it for good."
|
|
40
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Deleted accounts" }]}
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
{list === "migrate" ? (
|
|
44
|
+
<Empty className="rounded-xl border border-dashed">
|
|
45
|
+
<EmptyHeader>
|
|
46
|
+
<EmptyTitle>This app doesn't keep deleted accounts yet</EmptyTitle>
|
|
47
|
+
<EmptyDescription>
|
|
48
|
+
Deleting a user still removes them. Run <code>nevela upgrade</code>, then <code>nevela migrate</code>, and from then on a deleted account is
|
|
49
|
+
kept here.
|
|
50
|
+
</EmptyDescription>
|
|
51
|
+
</EmptyHeader>
|
|
52
|
+
</Empty>
|
|
53
|
+
) : (
|
|
54
|
+
<>
|
|
55
|
+
{(meta.total > 0 || params.q) && (
|
|
56
|
+
<form className="relative w-full max-w-sm" role="search">
|
|
57
|
+
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
|
58
|
+
<Input type="search" name="q" defaultValue={params.q ?? ""} placeholder="Search by name or email" aria-label="Search deleted accounts" className="pl-8" />
|
|
59
|
+
</form>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{accounts.length === 0 && blocked.length > 0 && !params.q ? (
|
|
63
|
+
// With blocked emails to show below, a line is enough: the tall empty box would push them off the screen.
|
|
64
|
+
<p className="rounded-xl border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">No deleted accounts at the moment.</p>
|
|
65
|
+
) : accounts.length === 0 ? (
|
|
66
|
+
<Empty className="rounded-xl border border-dashed">
|
|
67
|
+
<EmptyHeader>
|
|
68
|
+
<EmptyTitle>{params.q ? "Nobody matches" : "No deleted accounts"}</EmptyTitle>
|
|
69
|
+
<EmptyDescription>
|
|
70
|
+
{params.q ? "Try a different name or email." : "When someone closes their account, or you delete a user, the account waits here."}
|
|
71
|
+
</EmptyDescription>
|
|
72
|
+
</EmptyHeader>
|
|
73
|
+
</Empty>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="overflow-x-auto rounded-xl border">
|
|
76
|
+
<Table>
|
|
77
|
+
<TableHeader>
|
|
78
|
+
<TableRow>
|
|
79
|
+
<TableHead>Account</TableHead>
|
|
80
|
+
<TableHead>Roles</TableHead>
|
|
81
|
+
<TableHead>Closed</TableHead>
|
|
82
|
+
<TableHead className="w-64">
|
|
83
|
+
<span className="sr-only">Actions</span>
|
|
84
|
+
</TableHead>
|
|
85
|
+
</TableRow>
|
|
86
|
+
</TableHeader>
|
|
87
|
+
<TableBody>
|
|
88
|
+
{accounts.map((account) => (
|
|
89
|
+
<TableRow key={account.id}>
|
|
90
|
+
<TableCell>
|
|
91
|
+
<div className="flex items-center gap-3">
|
|
92
|
+
<UserAvatar user={{ name: account.name, email: account.email, image: account.image }} className="size-8" />
|
|
93
|
+
<div className="flex min-w-0 flex-col">
|
|
94
|
+
<span className="truncate text-sm font-medium">{account.name || account.email}</span>
|
|
95
|
+
<span className="truncate text-xs text-muted-foreground">{account.email}</span>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</TableCell>
|
|
99
|
+
<TableCell>
|
|
100
|
+
<div className="flex flex-wrap gap-1">
|
|
101
|
+
{account.roles.length === 0 && <span className="text-sm text-muted-foreground">None</span>}
|
|
102
|
+
{account.roles.map((role) => (
|
|
103
|
+
<Badge key={role.id} variant="secondary">
|
|
104
|
+
{role.name}
|
|
105
|
+
</Badge>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
</TableCell>
|
|
109
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
110
|
+
<div className="flex flex-col">
|
|
111
|
+
<LocalTime value={account.closedAt} />
|
|
112
|
+
<span className="text-xs">
|
|
113
|
+
{account.closedBy === "self" ? "by themselves" : account.closedByName ? `by ${account.closedByName}` : account.closedBy === "admin" ? "by an administrator" : ""}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
</TableCell>
|
|
117
|
+
<TableCell>
|
|
118
|
+
{/* Only accounts that may do no more than you: the rule the Users screen has. */}
|
|
119
|
+
{account.withinYours && <DeletedAccountActions id={account.id} who={account.name || account.email} email={account.email} />}
|
|
120
|
+
</TableCell>
|
|
121
|
+
</TableRow>
|
|
122
|
+
))}
|
|
123
|
+
</TableBody>
|
|
124
|
+
</Table>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{meta.total > 0 && (
|
|
129
|
+
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
|
130
|
+
<p className="tabular-nums">
|
|
131
|
+
{(meta.page - 1) * meta.perPage + 1}–{Math.min(meta.page * meta.perPage, meta.total)} of {meta.total.toLocaleString()} deleted{" "}
|
|
132
|
+
{meta.total === 1 ? "account" : "accounts"}
|
|
133
|
+
</p>
|
|
134
|
+
{meta.totalPages > 1 && (
|
|
135
|
+
<div className="flex items-center gap-2">
|
|
136
|
+
{meta.page > 1 ? (
|
|
137
|
+
<Button variant="outline" size="sm" asChild>
|
|
138
|
+
<Link href={pageHref(meta.page - 1)}>Previous</Link>
|
|
139
|
+
</Button>
|
|
140
|
+
) : (
|
|
141
|
+
<Button variant="outline" size="sm" disabled>
|
|
142
|
+
Previous
|
|
143
|
+
</Button>
|
|
144
|
+
)}
|
|
145
|
+
{meta.page < meta.totalPages ? (
|
|
146
|
+
<Button variant="outline" size="sm" asChild>
|
|
147
|
+
<Link href={pageHref(meta.page + 1)}>Next</Link>
|
|
148
|
+
</Button>
|
|
149
|
+
) : (
|
|
150
|
+
<Button variant="outline" size="sm" disabled>
|
|
151
|
+
Next
|
|
152
|
+
</Button>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{blocked.length > 0 && (
|
|
160
|
+
<section aria-labelledby="blocked-emails" className="flex flex-col gap-3">
|
|
161
|
+
<div className="flex flex-col gap-1">
|
|
162
|
+
<h2 id="blocked-emails" className="text-lg font-semibold tracking-tight">
|
|
163
|
+
Emails that can't sign up
|
|
164
|
+
</h2>
|
|
165
|
+
<p className="text-sm text-muted-foreground">
|
|
166
|
+
Their accounts were removed for good. Only a fingerprint of each address is kept: enough to refuse it, not to read it back.
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
<div className="overflow-x-auto rounded-xl border">
|
|
170
|
+
<Table>
|
|
171
|
+
<TableHeader>
|
|
172
|
+
<TableRow>
|
|
173
|
+
<TableHead>Email</TableHead>
|
|
174
|
+
<TableHead>Blocked</TableHead>
|
|
175
|
+
<TableHead className="w-36">
|
|
176
|
+
<span className="sr-only">Actions</span>
|
|
177
|
+
</TableHead>
|
|
178
|
+
</TableRow>
|
|
179
|
+
</TableHeader>
|
|
180
|
+
<TableBody>
|
|
181
|
+
{blocked.map((entry) => (
|
|
182
|
+
<TableRow key={entry.id}>
|
|
183
|
+
<TableCell className="font-mono text-sm">{entry.hint}</TableCell>
|
|
184
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
185
|
+
<LocalTime value={entry.blockedAt} />
|
|
186
|
+
</TableCell>
|
|
187
|
+
<TableCell className="text-right">
|
|
188
|
+
<AllowEmailButton id={entry.id} hint={entry.hint} />
|
|
189
|
+
</TableCell>
|
|
190
|
+
</TableRow>
|
|
191
|
+
))}
|
|
192
|
+
</TableBody>
|
|
193
|
+
</Table>
|
|
194
|
+
</div>
|
|
195
|
+
</section>
|
|
196
|
+
)}
|
|
197
|
+
</>
|
|
198
|
+
)}
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
201
|
+
}
|