create-nevela 0.5.3 → 0.7.0
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/Auth/Account.php +7 -2
- package/template/laravel/src/Console/StatusCommand.php +10 -3
- 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 +37 -53
- 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
|
@@ -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) {
|
|
@@ -141,11 +147,8 @@ final class UsersController
|
|
|
141
147
|
if (! $self && ($refused = $this->aboveCaller($caller, $user, 'change'))) {
|
|
142
148
|
return $refused;
|
|
143
149
|
}
|
|
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
|
-
}
|
|
150
|
+
if (isset($input['email']) && ($taken = $this->emailTaken($input['email'], $user))) {
|
|
151
|
+
return $taken;
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
$roles = null;
|
|
@@ -203,7 +206,11 @@ final class UsersController
|
|
|
203
206
|
return response()->json(self::present(collect([$user->refresh()]), $caller)[0]);
|
|
204
207
|
}
|
|
205
208
|
|
|
206
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* DELETE _nevela/users/{id}: close the account. It is signed out everywhere and kept
|
|
211
|
+
* under Deleted accounts, to be restored or removed for good. In an app that hasn't
|
|
212
|
+
* run the migration for that yet, it is removed, as it always was.
|
|
213
|
+
*/
|
|
207
214
|
public function destroy(Request $request, string $id): JsonResponse|Response
|
|
208
215
|
{
|
|
209
216
|
Gate::authorize('users.delete');
|
|
@@ -219,18 +226,11 @@ final class UsersController
|
|
|
219
226
|
return $this->refuse('LAST_ADMIN', 'This is the only administrator. Make someone else an administrator first.', 409);
|
|
220
227
|
}
|
|
221
228
|
|
|
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();
|
|
229
|
+
if (ClosedAccounts::ready()) {
|
|
230
|
+
ClosedAccounts::close($user, $caller);
|
|
231
|
+
} else {
|
|
232
|
+
ClosedAccounts::remove($user);
|
|
233
|
+
}
|
|
234
234
|
|
|
235
235
|
return response()->noContent();
|
|
236
236
|
}
|
|
@@ -250,47 +250,31 @@ final class UsersController
|
|
|
250
250
|
|
|
251
251
|
private function find(string $id): mixed
|
|
252
252
|
{
|
|
253
|
-
return
|
|
253
|
+
return ClosedAccounts::open()->find($id) ?? throw new NotFoundHttpException('No such user.');
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
|
-
* A
|
|
257
|
+
* A 422 when an email can't be given to an account: someone has it, a deleted account
|
|
258
|
+
* has it, or its account was removed for good and the address is blocked.
|
|
258
259
|
*
|
|
259
|
-
*
|
|
260
|
-
* a switched-off administrator is still not for a lesser manager to give a new
|
|
261
|
-
* password and switch back on.
|
|
260
|
+
* @param mixed $except The account being changed, whose own email it may be
|
|
262
261
|
*/
|
|
263
|
-
private function
|
|
262
|
+
private function emailTaken(string $email, mixed $except = null): ?JsonResponse
|
|
264
263
|
{
|
|
265
|
-
|
|
264
|
+
$issue = fn (string $message) => response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => $message]]], 422);
|
|
265
|
+
$holder = Account::findByEmail($email);
|
|
266
|
+
if ($holder && $except !== null && (string) $holder->getAuthIdentifier() === (string) $except->getAuthIdentifier()) {
|
|
266
267
|
return null;
|
|
267
268
|
}
|
|
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);
|
|
269
|
+
if ($holder && ! ClosedAccounts::isClosed($holder)) {
|
|
270
|
+
return $issue('Someone already has this email.');
|
|
285
271
|
}
|
|
286
272
|
|
|
287
|
-
return
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
{
|
|
293
|
-
return Access::rolesOf($user)->contains(fn (Role $role) => Permissions::hasAll($role->grants));
|
|
273
|
+
return match ($holder ? 'closed' : ClosedAccounts::standing($email, $except)) {
|
|
274
|
+
'closed' => $issue('A deleted account has this email. Restore it from Deleted accounts.'),
|
|
275
|
+
'blocked' => $issue('This email is blocked: its account was removed for good. Allow it again under Deleted accounts first.'),
|
|
276
|
+
default => null,
|
|
277
|
+
};
|
|
294
278
|
}
|
|
295
279
|
|
|
296
280
|
/**
|
|
@@ -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.0';
|
|
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
|
+
}
|
|
@@ -115,7 +115,7 @@ export default async function UsersPage({ searchParams }: { searchParams: Promis
|
|
|
115
115
|
</TableCell>
|
|
116
116
|
<TableCell className="text-sm text-muted-foreground">{user.lastActiveAt ? <LocalTime value={user.lastActiveAt} /> : "Not yet"}</TableCell>
|
|
117
117
|
<TableCell>
|
|
118
|
-
<UserRowActions user={user} canEdit={may("users.edit") && (user.withinYours || user.isSelf)} canDelete={may("users.delete") && user.withinYours} />
|
|
118
|
+
<UserRowActions user={user} canEdit={may("users.edit") && (user.withinYours || user.isSelf)} canDelete={may("users.delete") && user.withinYours} keepsDeleted={Boolean(list?.meta.keepsDeleted)} />
|
|
119
119
|
</TableCell>
|
|
120
120
|
</TableRow>
|
|
121
121
|
))}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Profile } from "@/components/account/sections";
|
|
1
|
+
import { CloseAccount, Profile } from "@/components/account/sections";
|
|
2
|
+
import { authConfig } from "@/lib/auth-config";
|
|
2
3
|
import { requireSession } from "@/lib/session";
|
|
3
4
|
|
|
4
5
|
export const metadata = { title: "Profile" };
|
|
@@ -6,14 +7,18 @@ export const metadata = { title: "Profile" };
|
|
|
6
7
|
export default async function ProfilePage() {
|
|
7
8
|
const { user } = await requireSession("/dashboard/account");
|
|
8
9
|
return (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
<>
|
|
11
|
+
<Profile
|
|
12
|
+
user={{
|
|
13
|
+
name: user.name ?? "",
|
|
14
|
+
email: user.email,
|
|
15
|
+
emailVerified: user.emailVerified,
|
|
16
|
+
twoFactorEnabled: user.twoFactorEnabled,
|
|
17
|
+
avatar: user.avatar,
|
|
18
|
+
}}
|
|
19
|
+
/>
|
|
20
|
+
{/* Only where people may close their own (config/nevela.php: auth.close_account). */}
|
|
21
|
+
{authConfig.closeAccount && <CloseAccount email={user.email} />}
|
|
22
|
+
</>
|
|
18
23
|
);
|
|
19
24
|
}
|
|
@@ -33,7 +33,8 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|
|
33
33
|
const account = { name: user.name, email: user.email, image: user.avatar ? fileUrl(user.avatar, "thumb") : null };
|
|
34
34
|
// The screens for managing people, for those whose roles allow it.
|
|
35
35
|
const { isAdmin, may } = await dashboardSession();
|
|
36
|
-
|
|
36
|
+
// Deleted accounts are for whoever may delete one: the same people who can put it there.
|
|
37
|
+
const access = { users: may("users.view"), roles: may("roles.view"), deleted: may("users.delete") };
|
|
37
38
|
// The trash is for those who may delete: whoever can put a record there can deal with it there.
|
|
38
39
|
const trash = (await Promise.all(allResources().map((resource) => mayDo(resource, "delete")))).some(Boolean);
|
|
39
40
|
|